letta-nightly 0.6.27.dev20250219104103__py3-none-any.whl → 0.6.28.dev20250220163833__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 +1 -1
- letta/agent.py +13 -1
- letta/client/client.py +2 -0
- letta/constants.py +2 -0
- letta/functions/schema_generator.py +6 -6
- letta/helpers/converters.py +153 -0
- letta/helpers/tool_rule_solver.py +11 -1
- letta/llm_api/anthropic.py +10 -5
- letta/llm_api/aws_bedrock.py +1 -1
- letta/llm_api/azure_openai_constants.py +1 -0
- letta/llm_api/deepseek.py +303 -0
- letta/llm_api/llm_api_tools.py +81 -1
- letta/llm_api/openai.py +13 -0
- letta/local_llm/chat_completion_proxy.py +15 -2
- letta/local_llm/lmstudio/api.py +75 -1
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +14 -5
- letta/orm/custom_columns.py +31 -110
- letta/orm/identity.py +39 -0
- letta/orm/organization.py +2 -0
- letta/schemas/agent.py +13 -1
- letta/schemas/identity.py +44 -0
- letta/schemas/llm_config.py +2 -0
- letta/schemas/message.py +1 -1
- letta/schemas/openai/chat_completion_response.py +2 -0
- letta/schemas/providers.py +72 -1
- letta/schemas/tool_rule.py +9 -1
- letta/serialize_schemas/__init__.py +1 -0
- letta/serialize_schemas/agent.py +36 -0
- letta/serialize_schemas/base.py +12 -0
- letta/serialize_schemas/custom_fields.py +69 -0
- letta/serialize_schemas/message.py +15 -0
- letta/server/db.py +111 -0
- letta/server/rest_api/app.py +8 -0
- letta/server/rest_api/interface.py +114 -9
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +7 -1
- letta/server/rest_api/routers/v1/identities.py +111 -0
- letta/server/server.py +13 -116
- letta/services/agent_manager.py +54 -6
- letta/services/block_manager.py +1 -1
- letta/services/helpers/agent_manager_helper.py +15 -0
- letta/services/identity_manager.py +140 -0
- letta/services/job_manager.py +1 -1
- letta/services/message_manager.py +1 -1
- letta/services/organization_manager.py +1 -1
- letta/services/passage_manager.py +1 -1
- letta/services/provider_manager.py +1 -1
- letta/services/sandbox_config_manager.py +1 -1
- letta/services/source_manager.py +1 -1
- letta/services/step_manager.py +1 -1
- letta/services/tool_manager.py +1 -1
- letta/services/user_manager.py +1 -1
- letta/settings.py +3 -0
- letta/tracing.py +205 -0
- letta/utils.py +4 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/METADATA +9 -2
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/RECORD +61 -48
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/LICENSE +0 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/WHEEL +0 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/entry_points.txt +0 -0
letta/llm_api/llm_api_tools.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import random
|
|
2
3
|
import time
|
|
3
4
|
from typing import List, Optional, Union
|
|
@@ -13,6 +14,7 @@ from letta.llm_api.anthropic import (
|
|
|
13
14
|
)
|
|
14
15
|
from letta.llm_api.aws_bedrock import has_valid_aws_credentials
|
|
15
16
|
from letta.llm_api.azure_openai import azure_openai_chat_completions_request
|
|
17
|
+
from letta.llm_api.deepseek import build_deepseek_chat_completions_request, convert_deepseek_response_to_chatcompletion
|
|
16
18
|
from letta.llm_api.google_ai import convert_tools_to_google_ai_format, google_ai_chat_completions_request
|
|
17
19
|
from letta.llm_api.helpers import add_inner_thoughts_to_functions, unpack_all_inner_thoughts_from_kwargs
|
|
18
20
|
from letta.llm_api.openai import (
|
|
@@ -29,8 +31,9 @@ from letta.schemas.openai.chat_completion_request import ChatCompletionRequest,
|
|
|
29
31
|
from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
|
|
30
32
|
from letta.settings import ModelSettings
|
|
31
33
|
from letta.streaming_interface import AgentChunkStreamingInterface, AgentRefreshStreamingInterface
|
|
34
|
+
from letta.tracing import log_event, trace_method
|
|
32
35
|
|
|
33
|
-
LLM_API_PROVIDER_OPTIONS = ["openai", "azure", "anthropic", "google_ai", "cohere", "local", "groq"]
|
|
36
|
+
LLM_API_PROVIDER_OPTIONS = ["openai", "azure", "anthropic", "google_ai", "cohere", "local", "groq", "deepseek"]
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
def retry_with_exponential_backoff(
|
|
@@ -68,9 +71,28 @@ def retry_with_exponential_backoff(
|
|
|
68
71
|
if http_err.response.status_code in error_codes:
|
|
69
72
|
# Increment retries
|
|
70
73
|
num_retries += 1
|
|
74
|
+
log_event(
|
|
75
|
+
"llm_retry_attempt",
|
|
76
|
+
{
|
|
77
|
+
"attempt": num_retries,
|
|
78
|
+
"delay": delay,
|
|
79
|
+
"status_code": http_err.response.status_code,
|
|
80
|
+
"error_type": type(http_err).__name__,
|
|
81
|
+
"error": str(http_err),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
71
84
|
|
|
72
85
|
# Check if max retries has been reached
|
|
73
86
|
if num_retries > max_retries:
|
|
87
|
+
log_event(
|
|
88
|
+
"llm_max_retries_exceeded",
|
|
89
|
+
{
|
|
90
|
+
"max_retries": max_retries,
|
|
91
|
+
"status_code": http_err.response.status_code,
|
|
92
|
+
"error_type": type(http_err).__name__,
|
|
93
|
+
"error": str(http_err),
|
|
94
|
+
},
|
|
95
|
+
)
|
|
74
96
|
raise RateLimitExceededError("Maximum number of retries exceeded", max_retries=max_retries)
|
|
75
97
|
|
|
76
98
|
# Increment the delay
|
|
@@ -84,15 +106,21 @@ def retry_with_exponential_backoff(
|
|
|
84
106
|
time.sleep(delay)
|
|
85
107
|
else:
|
|
86
108
|
# For other HTTP errors, re-raise the exception
|
|
109
|
+
log_event(
|
|
110
|
+
"llm_non_retryable_error",
|
|
111
|
+
{"status_code": http_err.response.status_code, "error_type": type(http_err).__name__, "error": str(http_err)},
|
|
112
|
+
)
|
|
87
113
|
raise
|
|
88
114
|
|
|
89
115
|
# Raise exceptions for any errors not specified
|
|
90
116
|
except Exception as e:
|
|
117
|
+
log_event("llm_unexpected_error", {"error_type": type(e).__name__, "error": str(e)})
|
|
91
118
|
raise e
|
|
92
119
|
|
|
93
120
|
return wrapper
|
|
94
121
|
|
|
95
122
|
|
|
123
|
+
@trace_method("LLM Request")
|
|
96
124
|
@retry_with_exponential_backoff
|
|
97
125
|
def create(
|
|
98
126
|
# agent_state: AgentState,
|
|
@@ -453,10 +481,62 @@ def create(
|
|
|
453
481
|
),
|
|
454
482
|
)
|
|
455
483
|
|
|
484
|
+
elif llm_config.model_endpoint_type == "deepseek":
|
|
485
|
+
if model_settings.deepseek_api_key is None and llm_config.model_endpoint == "":
|
|
486
|
+
# only is a problem if we are *not* using an openai proxy
|
|
487
|
+
raise LettaConfigurationError(message="DeepSeek key is missing from letta config file", missing_fields=["deepseek_api_key"])
|
|
488
|
+
|
|
489
|
+
data = build_deepseek_chat_completions_request(
|
|
490
|
+
llm_config,
|
|
491
|
+
messages,
|
|
492
|
+
user_id,
|
|
493
|
+
functions,
|
|
494
|
+
function_call,
|
|
495
|
+
use_tool_naming,
|
|
496
|
+
llm_config.max_tokens,
|
|
497
|
+
)
|
|
498
|
+
if stream: # Client requested token streaming
|
|
499
|
+
data.stream = True
|
|
500
|
+
assert isinstance(stream_interface, AgentChunkStreamingInterface) or isinstance(
|
|
501
|
+
stream_interface, AgentRefreshStreamingInterface
|
|
502
|
+
), type(stream_interface)
|
|
503
|
+
response = openai_chat_completions_process_stream(
|
|
504
|
+
url=llm_config.model_endpoint,
|
|
505
|
+
api_key=model_settings.deepseek_api_key,
|
|
506
|
+
chat_completion_request=data,
|
|
507
|
+
stream_interface=stream_interface,
|
|
508
|
+
)
|
|
509
|
+
else: # Client did not request token streaming (expect a blocking backend response)
|
|
510
|
+
data.stream = False
|
|
511
|
+
if isinstance(stream_interface, AgentChunkStreamingInterface):
|
|
512
|
+
stream_interface.stream_start()
|
|
513
|
+
try:
|
|
514
|
+
response = openai_chat_completions_request(
|
|
515
|
+
url=llm_config.model_endpoint,
|
|
516
|
+
api_key=model_settings.deepseek_api_key,
|
|
517
|
+
chat_completion_request=data,
|
|
518
|
+
)
|
|
519
|
+
finally:
|
|
520
|
+
if isinstance(stream_interface, AgentChunkStreamingInterface):
|
|
521
|
+
stream_interface.stream_end()
|
|
522
|
+
"""
|
|
523
|
+
if llm_config.put_inner_thoughts_in_kwargs:
|
|
524
|
+
response = unpack_all_inner_thoughts_from_kwargs(response=response, inner_thoughts_key=INNER_THOUGHTS_KWARG)
|
|
525
|
+
"""
|
|
526
|
+
response = convert_deepseek_response_to_chatcompletion(response)
|
|
527
|
+
return response
|
|
528
|
+
|
|
456
529
|
# local model
|
|
457
530
|
else:
|
|
458
531
|
if stream:
|
|
459
532
|
raise NotImplementedError(f"Streaming not yet implemented for {llm_config.model_endpoint_type}")
|
|
533
|
+
|
|
534
|
+
if "DeepSeek-R1".lower() in llm_config.model.lower(): # TODO: move this to the llm_config.
|
|
535
|
+
messages[0].content[0].text += f"<available functions> {''.join(json.dumps(f) for f in functions)} </available functions>"
|
|
536
|
+
messages[0].content[
|
|
537
|
+
0
|
|
538
|
+
].text += f'Select best function to call simply by responding with a single json block with the keys "function" and "params". Use double quotes around the arguments.'
|
|
539
|
+
|
|
460
540
|
return get_chat_completion(
|
|
461
541
|
model=llm_config.model,
|
|
462
542
|
messages=messages,
|
letta/llm_api/openai.py
CHANGED
|
@@ -166,6 +166,11 @@ def openai_chat_completions_process_stream(
|
|
|
166
166
|
create_message_id: bool = True,
|
|
167
167
|
create_message_datetime: bool = True,
|
|
168
168
|
override_tool_call_id: bool = True,
|
|
169
|
+
# if we expect reasoning content in the response,
|
|
170
|
+
# then we should emit reasoning_content as "inner_thoughts"
|
|
171
|
+
# however, we don't necessarily want to put these
|
|
172
|
+
# expect_reasoning_content: bool = False,
|
|
173
|
+
expect_reasoning_content: bool = True,
|
|
169
174
|
) -> ChatCompletionResponse:
|
|
170
175
|
"""Process a streaming completion response, and return a ChatCompletionRequest at the end.
|
|
171
176
|
|
|
@@ -250,6 +255,7 @@ def openai_chat_completions_process_stream(
|
|
|
250
255
|
chat_completion_chunk,
|
|
251
256
|
message_id=chat_completion_response.id if create_message_id else chat_completion_chunk.id,
|
|
252
257
|
message_date=chat_completion_response.created if create_message_datetime else chat_completion_chunk.created,
|
|
258
|
+
expect_reasoning_content=expect_reasoning_content,
|
|
253
259
|
)
|
|
254
260
|
elif isinstance(stream_interface, AgentRefreshStreamingInterface):
|
|
255
261
|
stream_interface.process_refresh(chat_completion_response)
|
|
@@ -290,6 +296,13 @@ def openai_chat_completions_process_stream(
|
|
|
290
296
|
else:
|
|
291
297
|
accum_message.content += content_delta
|
|
292
298
|
|
|
299
|
+
if expect_reasoning_content and message_delta.reasoning_content is not None:
|
|
300
|
+
reasoning_content_delta = message_delta.reasoning_content
|
|
301
|
+
if accum_message.reasoning_content is None:
|
|
302
|
+
accum_message.reasoning_content = reasoning_content_delta
|
|
303
|
+
else:
|
|
304
|
+
accum_message.reasoning_content += reasoning_content_delta
|
|
305
|
+
|
|
293
306
|
# TODO(charles) make sure this works for parallel tool calling?
|
|
294
307
|
if message_delta.tool_calls is not None:
|
|
295
308
|
tool_calls_delta = message_delta.tool_calls
|
|
@@ -14,7 +14,7 @@ from letta.local_llm.grammars.gbnf_grammar_generator import create_dynamic_model
|
|
|
14
14
|
from letta.local_llm.koboldcpp.api import get_koboldcpp_completion
|
|
15
15
|
from letta.local_llm.llamacpp.api import get_llamacpp_completion
|
|
16
16
|
from letta.local_llm.llm_chat_completion_wrappers import simple_summary_wrapper
|
|
17
|
-
from letta.local_llm.lmstudio.api import get_lmstudio_completion
|
|
17
|
+
from letta.local_llm.lmstudio.api import get_lmstudio_completion, get_lmstudio_completion_chatcompletions
|
|
18
18
|
from letta.local_llm.ollama.api import get_ollama_completion
|
|
19
19
|
from letta.local_llm.utils import count_tokens, get_available_wrappers
|
|
20
20
|
from letta.local_llm.vllm.api import get_vllm_completion
|
|
@@ -141,11 +141,24 @@ def get_chat_completion(
|
|
|
141
141
|
f"Failed to convert ChatCompletion messages into prompt string with wrapper {str(llm_wrapper)} - error: {str(e)}"
|
|
142
142
|
)
|
|
143
143
|
|
|
144
|
+
# get the schema for the model
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
if functions_python is not None:
|
|
148
|
+
model_schema = generate_schema(functions)
|
|
149
|
+
else:
|
|
150
|
+
model_schema = None
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
# Run the LLM
|
|
144
154
|
try:
|
|
155
|
+
result_reasoning = None
|
|
145
156
|
if endpoint_type == "webui":
|
|
146
157
|
result, usage = get_webui_completion(endpoint, auth_type, auth_key, prompt, context_window, grammar=grammar)
|
|
147
158
|
elif endpoint_type == "webui-legacy":
|
|
148
159
|
result, usage = get_webui_completion_legacy(endpoint, auth_type, auth_key, prompt, context_window, grammar=grammar)
|
|
160
|
+
elif endpoint_type == "lmstudio-chatcompletions":
|
|
161
|
+
result, usage, result_reasoning = get_lmstudio_completion_chatcompletions(endpoint, auth_type, auth_key, model, messages)
|
|
149
162
|
elif endpoint_type == "lmstudio":
|
|
150
163
|
result, usage = get_lmstudio_completion(endpoint, auth_type, auth_key, prompt, context_window, api="completions")
|
|
151
164
|
elif endpoint_type == "lmstudio-legacy":
|
|
@@ -214,7 +227,7 @@ def get_chat_completion(
|
|
|
214
227
|
index=0,
|
|
215
228
|
message=Message(
|
|
216
229
|
role=chat_completion_result["role"],
|
|
217
|
-
content=chat_completion_result["content"],
|
|
230
|
+
content=result_reasoning if result_reasoning is not None else chat_completion_result["content"],
|
|
218
231
|
tool_calls=(
|
|
219
232
|
[ToolCall(id=get_tool_call_id(), type="function", function=chat_completion_result["function_call"])]
|
|
220
233
|
if "function_call" in chat_completion_result
|
letta/local_llm/lmstudio/api.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from urllib.parse import urljoin
|
|
2
3
|
|
|
3
4
|
from letta.local_llm.settings.settings import get_completions_settings
|
|
@@ -6,6 +7,73 @@ from letta.utils import count_tokens
|
|
|
6
7
|
|
|
7
8
|
LMSTUDIO_API_CHAT_SUFFIX = "/v1/chat/completions"
|
|
8
9
|
LMSTUDIO_API_COMPLETIONS_SUFFIX = "/v1/completions"
|
|
10
|
+
LMSTUDIO_API_CHAT_COMPLETIONS_SUFFIX = "/v1/chat/completions"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_lmstudio_completion_chatcompletions(endpoint, auth_type, auth_key, model, messages):
|
|
14
|
+
"""
|
|
15
|
+
This is the request we need to send
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
"model": "deepseek-r1-distill-qwen-7b",
|
|
19
|
+
"messages": [
|
|
20
|
+
{ "role": "system", "content": "Always answer in rhymes. Today is Thursday" },
|
|
21
|
+
{ "role": "user", "content": "What day is it today?" },
|
|
22
|
+
{ "role": "user", "content": "What day is it today?" }],
|
|
23
|
+
"temperature": 0.7,
|
|
24
|
+
"max_tokens": -1,
|
|
25
|
+
"stream": false
|
|
26
|
+
"""
|
|
27
|
+
from letta.utils import printd
|
|
28
|
+
|
|
29
|
+
URI = endpoint + LMSTUDIO_API_CHAT_COMPLETIONS_SUFFIX
|
|
30
|
+
request = {"model": model, "messages": messages}
|
|
31
|
+
|
|
32
|
+
response = post_json_auth_request(uri=URI, json_payload=request, auth_type=auth_type, auth_key=auth_key)
|
|
33
|
+
|
|
34
|
+
# Get the reasoning from the model
|
|
35
|
+
if response.status_code == 200:
|
|
36
|
+
result_full = response.json()
|
|
37
|
+
result_reasoning = result_full["choices"][0]["message"].get("reasoning_content")
|
|
38
|
+
result = result_full["choices"][0]["message"]["content"]
|
|
39
|
+
usage = result_full["usage"]
|
|
40
|
+
|
|
41
|
+
# See if result is json
|
|
42
|
+
try:
|
|
43
|
+
function_call = json.loads(result)
|
|
44
|
+
if "function" in function_call and "params" in function_call:
|
|
45
|
+
return result, usage, result_reasoning
|
|
46
|
+
else:
|
|
47
|
+
print("Did not get json on without json constraint, attempting with json decoding")
|
|
48
|
+
except Exception as e:
|
|
49
|
+
print(f"Did not get json on without json constraint, attempting with json decoding: {e}")
|
|
50
|
+
|
|
51
|
+
request["messages"].append({"role": "assistant", "content": result_reasoning})
|
|
52
|
+
request["messages"].append({"role": "user", "content": ""}) # last message must be user
|
|
53
|
+
# Now run with json decoding to get the function
|
|
54
|
+
request["response_format"] = {
|
|
55
|
+
"type": "json_schema",
|
|
56
|
+
"json_schema": {
|
|
57
|
+
"name": "function_call",
|
|
58
|
+
"strict": "true",
|
|
59
|
+
"schema": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {"function": {"type": "string"}, "params": {"type": "object"}},
|
|
62
|
+
"required": ["function", "params"],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
response = post_json_auth_request(uri=URI, json_payload=request, auth_type=auth_type, auth_key=auth_key)
|
|
68
|
+
if response.status_code == 200:
|
|
69
|
+
result_full = response.json()
|
|
70
|
+
printd(f"JSON API response:\n{result_full}")
|
|
71
|
+
result = result_full["choices"][0]["message"]["content"]
|
|
72
|
+
# add usage with previous call, merge with prev usage
|
|
73
|
+
for key, value in result_full["usage"].items():
|
|
74
|
+
usage[key] += value
|
|
75
|
+
|
|
76
|
+
return result, usage, result_reasoning
|
|
9
77
|
|
|
10
78
|
|
|
11
79
|
def get_lmstudio_completion(endpoint, auth_type, auth_key, prompt, context_window, api="completions"):
|
|
@@ -24,7 +92,8 @@ def get_lmstudio_completion(endpoint, auth_type, auth_key, prompt, context_windo
|
|
|
24
92
|
# This controls how LM studio handles context overflow
|
|
25
93
|
# In Letta we handle this ourselves, so this should be disabled
|
|
26
94
|
# "context_overflow_policy": 0,
|
|
27
|
-
"lmstudio": {"context_overflow_policy": 0}, # 0 = stop at limit
|
|
95
|
+
# "lmstudio": {"context_overflow_policy": 0}, # 0 = stop at limit
|
|
96
|
+
# "lmstudio": {"context_overflow_policy": "stopAtLimit"}, # https://github.com/letta-ai/letta/issues/1782
|
|
28
97
|
"stream": False,
|
|
29
98
|
"model": "local model",
|
|
30
99
|
}
|
|
@@ -72,6 +141,11 @@ def get_lmstudio_completion(endpoint, auth_type, auth_key, prompt, context_windo
|
|
|
72
141
|
elif api == "completions":
|
|
73
142
|
result = result_full["choices"][0]["text"]
|
|
74
143
|
usage = result_full.get("usage", None)
|
|
144
|
+
elif api == "chat/completions":
|
|
145
|
+
result = result_full["choices"][0]["content"]
|
|
146
|
+
result_full["choices"][0]["reasoning_content"]
|
|
147
|
+
usage = result_full.get("usage", None)
|
|
148
|
+
|
|
75
149
|
else:
|
|
76
150
|
# Example error: msg={"error":"Context length exceeded. Tokens in context: 8000, Context length: 8000"}
|
|
77
151
|
if "context length" in str(response.text).lower():
|
letta/orm/__init__.py
CHANGED
|
@@ -4,6 +4,7 @@ from letta.orm.base import Base
|
|
|
4
4
|
from letta.orm.block import Block
|
|
5
5
|
from letta.orm.blocks_agents import BlocksAgents
|
|
6
6
|
from letta.orm.file import FileMetadata
|
|
7
|
+
from letta.orm.identity import Identity
|
|
7
8
|
from letta.orm.job import Job
|
|
8
9
|
from letta.orm.job_messages import JobMessage
|
|
9
10
|
from letta.orm.message import Message
|
letta/orm/agent.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import uuid
|
|
2
2
|
from typing import TYPE_CHECKING, List, Optional
|
|
3
3
|
|
|
4
|
-
from sqlalchemy import JSON, Boolean, Index, String
|
|
4
|
+
from sqlalchemy import JSON, Boolean, ForeignKey, Index, String
|
|
5
5
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
6
|
|
|
7
7
|
from letta.orm.block import Block
|
|
8
8
|
from letta.orm.custom_columns import EmbeddingConfigColumn, LLMConfigColumn, ToolRulesColumn
|
|
9
|
+
from letta.orm.identity import Identity
|
|
9
10
|
from letta.orm.message import Message
|
|
10
11
|
from letta.orm.mixins import OrganizationMixin
|
|
11
12
|
from letta.orm.organization import Organization
|
|
@@ -15,10 +16,11 @@ from letta.schemas.agent import AgentType
|
|
|
15
16
|
from letta.schemas.embedding_config import EmbeddingConfig
|
|
16
17
|
from letta.schemas.llm_config import LLMConfig
|
|
17
18
|
from letta.schemas.memory import Memory
|
|
18
|
-
from letta.schemas.tool_rule import
|
|
19
|
+
from letta.schemas.tool_rule import ToolRule
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
21
22
|
from letta.orm.agents_tags import AgentsTags
|
|
23
|
+
from letta.orm.identity import Identity
|
|
22
24
|
from letta.orm.organization import Organization
|
|
23
25
|
from letta.orm.source import Source
|
|
24
26
|
from letta.orm.tool import Tool
|
|
@@ -59,6 +61,14 @@ class Agent(SqlalchemyBase, OrganizationMixin):
|
|
|
59
61
|
template_id: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The id of the template the agent belongs to.")
|
|
60
62
|
base_template_id: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The base template id of the agent.")
|
|
61
63
|
|
|
64
|
+
# Identity
|
|
65
|
+
identity_id: Mapped[Optional[str]] = mapped_column(
|
|
66
|
+
String, ForeignKey("identities.id", ondelete="CASCADE"), nullable=True, doc="The id of the identity the agent belongs to."
|
|
67
|
+
)
|
|
68
|
+
identifier_key: Mapped[Optional[str]] = mapped_column(
|
|
69
|
+
String, nullable=True, doc="The identifier key of the identity the agent belongs to."
|
|
70
|
+
)
|
|
71
|
+
|
|
62
72
|
# Tool rules
|
|
63
73
|
tool_rules: Mapped[Optional[List[ToolRule]]] = mapped_column(ToolRulesColumn, doc="the tool rules for this agent.")
|
|
64
74
|
|
|
@@ -69,6 +79,7 @@ class Agent(SqlalchemyBase, OrganizationMixin):
|
|
|
69
79
|
|
|
70
80
|
# relationships
|
|
71
81
|
organization: Mapped["Organization"] = relationship("Organization", back_populates="agents")
|
|
82
|
+
identity: Mapped["Identity"] = relationship("Identity", back_populates="agents")
|
|
72
83
|
tool_exec_environment_variables: Mapped[List["AgentEnvironmentVariable"]] = relationship(
|
|
73
84
|
"AgentEnvironmentVariable",
|
|
74
85
|
back_populates="agent",
|
|
@@ -119,14 +130,12 @@ class Agent(SqlalchemyBase, OrganizationMixin):
|
|
|
119
130
|
viewonly=True, # Ensures SQLAlchemy doesn't attempt to manage this relationship
|
|
120
131
|
doc="All passages derived created by this agent.",
|
|
121
132
|
)
|
|
133
|
+
identity: Mapped[Optional["Identity"]] = relationship("Identity", back_populates="agents")
|
|
122
134
|
|
|
123
135
|
def to_pydantic(self) -> PydanticAgentState:
|
|
124
136
|
"""converts to the basic pydantic model counterpart"""
|
|
125
137
|
# add default rule for having send_message be a terminal tool
|
|
126
138
|
tool_rules = self.tool_rules
|
|
127
|
-
if not tool_rules:
|
|
128
|
-
tool_rules = [TerminalToolRule(tool_name="send_message"), TerminalToolRule(tool_name="send_message_to_agent_async")]
|
|
129
|
-
|
|
130
139
|
state = {
|
|
131
140
|
"id": self.id,
|
|
132
141
|
"organization_id": self.organization_id,
|
letta/orm/custom_columns.py
CHANGED
|
@@ -1,159 +1,80 @@
|
|
|
1
|
-
import base64
|
|
2
|
-
from typing import List, Union
|
|
3
|
-
|
|
4
|
-
import numpy as np
|
|
5
|
-
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall
|
|
6
|
-
from openai.types.chat.chat_completion_message_tool_call import Function as OpenAIFunction
|
|
7
1
|
from sqlalchemy import JSON
|
|
8
2
|
from sqlalchemy.types import BINARY, TypeDecorator
|
|
9
3
|
|
|
10
|
-
from letta.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
from letta.helpers.converters import (
|
|
5
|
+
deserialize_embedding_config,
|
|
6
|
+
deserialize_llm_config,
|
|
7
|
+
deserialize_tool_calls,
|
|
8
|
+
deserialize_tool_rules,
|
|
9
|
+
deserialize_vector,
|
|
10
|
+
serialize_embedding_config,
|
|
11
|
+
serialize_llm_config,
|
|
12
|
+
serialize_tool_calls,
|
|
13
|
+
serialize_tool_rules,
|
|
14
|
+
serialize_vector,
|
|
15
|
+
)
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
class
|
|
17
|
-
"""Custom type for storing
|
|
18
|
+
class LLMConfigColumn(TypeDecorator):
|
|
19
|
+
"""Custom SQLAlchemy column type for storing LLMConfig as JSON."""
|
|
18
20
|
|
|
19
21
|
impl = JSON
|
|
20
22
|
cache_ok = True
|
|
21
23
|
|
|
22
|
-
def load_dialect_impl(self, dialect):
|
|
23
|
-
return dialect.type_descriptor(JSON())
|
|
24
|
-
|
|
25
24
|
def process_bind_param(self, value, dialect):
|
|
26
|
-
|
|
27
|
-
return value.model_dump()
|
|
28
|
-
return value
|
|
25
|
+
return serialize_llm_config(value)
|
|
29
26
|
|
|
30
27
|
def process_result_value(self, value, dialect):
|
|
31
|
-
|
|
32
|
-
return EmbeddingConfig(**value)
|
|
33
|
-
return value
|
|
28
|
+
return deserialize_llm_config(value)
|
|
34
29
|
|
|
35
30
|
|
|
36
|
-
class
|
|
37
|
-
"""Custom type for storing
|
|
31
|
+
class EmbeddingConfigColumn(TypeDecorator):
|
|
32
|
+
"""Custom SQLAlchemy column type for storing EmbeddingConfig as JSON."""
|
|
38
33
|
|
|
39
34
|
impl = JSON
|
|
40
35
|
cache_ok = True
|
|
41
36
|
|
|
42
|
-
def load_dialect_impl(self, dialect):
|
|
43
|
-
return dialect.type_descriptor(JSON())
|
|
44
|
-
|
|
45
37
|
def process_bind_param(self, value, dialect):
|
|
46
|
-
|
|
47
|
-
return value.model_dump()
|
|
48
|
-
return value
|
|
38
|
+
return serialize_embedding_config(value)
|
|
49
39
|
|
|
50
40
|
def process_result_value(self, value, dialect):
|
|
51
|
-
|
|
52
|
-
return LLMConfig(**value)
|
|
53
|
-
return value
|
|
41
|
+
return deserialize_embedding_config(value)
|
|
54
42
|
|
|
55
43
|
|
|
56
44
|
class ToolRulesColumn(TypeDecorator):
|
|
57
|
-
"""Custom type for storing a list of ToolRules as JSON"""
|
|
45
|
+
"""Custom SQLAlchemy column type for storing a list of ToolRules as JSON."""
|
|
58
46
|
|
|
59
47
|
impl = JSON
|
|
60
48
|
cache_ok = True
|
|
61
49
|
|
|
62
|
-
def load_dialect_impl(self, dialect):
|
|
63
|
-
return dialect.type_descriptor(JSON())
|
|
64
|
-
|
|
65
50
|
def process_bind_param(self, value, dialect):
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
d["type"] = d["type"].value
|
|
71
|
-
|
|
72
|
-
for d in data:
|
|
73
|
-
assert not (d["type"] == "ToolRule" and "children" not in d), "ToolRule does not have children field"
|
|
74
|
-
return data
|
|
75
|
-
return value
|
|
76
|
-
|
|
77
|
-
def process_result_value(self, value, dialect) -> List[Union[ChildToolRule, InitToolRule, TerminalToolRule]]:
|
|
78
|
-
"""Convert JSON back to a list of ToolRules."""
|
|
79
|
-
if value:
|
|
80
|
-
return [self.deserialize_tool_rule(rule_data) for rule_data in value]
|
|
81
|
-
return value
|
|
82
|
-
|
|
83
|
-
@staticmethod
|
|
84
|
-
def deserialize_tool_rule(data: dict) -> Union[ChildToolRule, InitToolRule, TerminalToolRule, ConditionalToolRule]:
|
|
85
|
-
"""Deserialize a dictionary to the appropriate ToolRule subclass based on the 'type'."""
|
|
86
|
-
rule_type = ToolRuleType(data.get("type")) # Remove 'type' field if it exists since it is a class var
|
|
87
|
-
if rule_type == ToolRuleType.run_first or rule_type == "InitToolRule":
|
|
88
|
-
data["type"] = ToolRuleType.run_first
|
|
89
|
-
return InitToolRule(**data)
|
|
90
|
-
elif rule_type == ToolRuleType.exit_loop or rule_type == "TerminalToolRule":
|
|
91
|
-
data["type"] = ToolRuleType.exit_loop
|
|
92
|
-
return TerminalToolRule(**data)
|
|
93
|
-
elif rule_type == ToolRuleType.constrain_child_tools or rule_type == "ToolRule":
|
|
94
|
-
data["type"] = ToolRuleType.constrain_child_tools
|
|
95
|
-
rule = ChildToolRule(**data)
|
|
96
|
-
return rule
|
|
97
|
-
elif rule_type == ToolRuleType.conditional:
|
|
98
|
-
rule = ConditionalToolRule(**data)
|
|
99
|
-
return rule
|
|
100
|
-
else:
|
|
101
|
-
raise ValueError(f"Unknown tool rule type: {rule_type}")
|
|
51
|
+
return serialize_tool_rules(value)
|
|
52
|
+
|
|
53
|
+
def process_result_value(self, value, dialect):
|
|
54
|
+
return deserialize_tool_rules(value)
|
|
102
55
|
|
|
103
56
|
|
|
104
57
|
class ToolCallColumn(TypeDecorator):
|
|
58
|
+
"""Custom SQLAlchemy column type for storing OpenAI ToolCall objects as JSON."""
|
|
105
59
|
|
|
106
60
|
impl = JSON
|
|
107
61
|
cache_ok = True
|
|
108
62
|
|
|
109
|
-
def load_dialect_impl(self, dialect):
|
|
110
|
-
return dialect.type_descriptor(JSON())
|
|
111
|
-
|
|
112
63
|
def process_bind_param(self, value, dialect):
|
|
113
|
-
|
|
114
|
-
values = []
|
|
115
|
-
for v in value:
|
|
116
|
-
if isinstance(v, OpenAIToolCall):
|
|
117
|
-
values.append(v.model_dump())
|
|
118
|
-
else:
|
|
119
|
-
values.append(v)
|
|
120
|
-
return values
|
|
121
|
-
|
|
122
|
-
return value
|
|
64
|
+
return serialize_tool_calls(value)
|
|
123
65
|
|
|
124
66
|
def process_result_value(self, value, dialect):
|
|
125
|
-
|
|
126
|
-
tools = []
|
|
127
|
-
for tool_value in value:
|
|
128
|
-
if "function" in tool_value:
|
|
129
|
-
tool_call_function = OpenAIFunction(**tool_value["function"])
|
|
130
|
-
del tool_value["function"]
|
|
131
|
-
else:
|
|
132
|
-
tool_call_function = None
|
|
133
|
-
tools.append(OpenAIToolCall(function=tool_call_function, **tool_value))
|
|
134
|
-
return tools
|
|
135
|
-
return value
|
|
67
|
+
return deserialize_tool_calls(value)
|
|
136
68
|
|
|
137
69
|
|
|
138
70
|
class CommonVector(TypeDecorator):
|
|
139
|
-
"""
|
|
71
|
+
"""Custom SQLAlchemy column type for storing vectors in SQLite."""
|
|
140
72
|
|
|
141
73
|
impl = BINARY
|
|
142
74
|
cache_ok = True
|
|
143
75
|
|
|
144
|
-
def load_dialect_impl(self, dialect):
|
|
145
|
-
return dialect.type_descriptor(BINARY())
|
|
146
|
-
|
|
147
76
|
def process_bind_param(self, value, dialect):
|
|
148
|
-
|
|
149
|
-
return value
|
|
150
|
-
if isinstance(value, list):
|
|
151
|
-
value = np.array(value, dtype=np.float32)
|
|
152
|
-
return base64.b64encode(value.tobytes())
|
|
77
|
+
return serialize_vector(value)
|
|
153
78
|
|
|
154
79
|
def process_result_value(self, value, dialect):
|
|
155
|
-
|
|
156
|
-
return value
|
|
157
|
-
if dialect.name == "sqlite":
|
|
158
|
-
value = base64.b64decode(value)
|
|
159
|
-
return np.frombuffer(value, dtype=np.float32)
|
|
80
|
+
return deserialize_vector(value, dialect)
|
letta/orm/identity.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import String, UniqueConstraint
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
|
+
|
|
7
|
+
from letta.orm.mixins import OrganizationMixin
|
|
8
|
+
from letta.orm.sqlalchemy_base import SqlalchemyBase
|
|
9
|
+
from letta.schemas.identity import Identity as PydanticIdentity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Identity(SqlalchemyBase, OrganizationMixin):
|
|
13
|
+
"""Identity ORM class"""
|
|
14
|
+
|
|
15
|
+
__tablename__ = "identities"
|
|
16
|
+
__pydantic_model__ = PydanticIdentity
|
|
17
|
+
__table_args__ = (UniqueConstraint("identifier_key", "project_id", "organization_id", name="unique_identifier_pid_org_id"),)
|
|
18
|
+
|
|
19
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"identity-{uuid.uuid4()}")
|
|
20
|
+
identifier_key: Mapped[str] = mapped_column(nullable=False, doc="External, user-generated identifier key of the identity.")
|
|
21
|
+
name: Mapped[str] = mapped_column(nullable=False, doc="The name of the identity.")
|
|
22
|
+
identity_type: Mapped[str] = mapped_column(nullable=False, doc="The type of the identity.")
|
|
23
|
+
project_id: Mapped[Optional[str]] = mapped_column(nullable=True, doc="The project id of the identity.")
|
|
24
|
+
|
|
25
|
+
# relationships
|
|
26
|
+
organization: Mapped["Organization"] = relationship("Organization", back_populates="identities")
|
|
27
|
+
agents: Mapped[List["Agent"]] = relationship("Agent", lazy="selectin", back_populates="identity")
|
|
28
|
+
|
|
29
|
+
def to_pydantic(self) -> PydanticIdentity:
|
|
30
|
+
state = {
|
|
31
|
+
"id": self.id,
|
|
32
|
+
"identifier_key": self.identifier_key,
|
|
33
|
+
"name": self.name,
|
|
34
|
+
"identity_type": self.identity_type,
|
|
35
|
+
"project_id": self.project_id,
|
|
36
|
+
"agents": [agent.to_pydantic() for agent in self.agents],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return self.__pydantic_model__(**state)
|
letta/orm/organization.py
CHANGED
|
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
|
|
|
9
9
|
|
|
10
10
|
from letta.orm.agent import Agent
|
|
11
11
|
from letta.orm.file import FileMetadata
|
|
12
|
+
from letta.orm.identity import Identity
|
|
12
13
|
from letta.orm.provider import Provider
|
|
13
14
|
from letta.orm.sandbox_config import AgentEnvironmentVariable
|
|
14
15
|
from letta.orm.tool import Tool
|
|
@@ -47,6 +48,7 @@ class Organization(SqlalchemyBase):
|
|
|
47
48
|
)
|
|
48
49
|
agent_passages: Mapped[List["AgentPassage"]] = relationship("AgentPassage", back_populates="organization", cascade="all, delete-orphan")
|
|
49
50
|
providers: Mapped[List["Provider"]] = relationship("Provider", back_populates="organization", cascade="all, delete-orphan")
|
|
51
|
+
identities: Mapped[List["Identity"]] = relationship("Identity", back_populates="organization", cascade="all, delete-orphan")
|
|
50
52
|
|
|
51
53
|
@property
|
|
52
54
|
def passages(self) -> List[Union["SourcePassage", "AgentPassage"]]:
|