letta-nightly 0.12.1.dev20251024104217__py3-none-any.whl → 0.13.0.dev20251025104015__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.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.dist-info}/METADATA +4 -2
- {letta_nightly-0.12.1.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.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.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.dist-info}/WHEEL +0 -0
- {letta_nightly-0.12.1.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.12.1.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,6 +2,10 @@ import uuid
|
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
from typing import List, Literal, Optional, Set
|
|
4
4
|
|
|
5
|
+
from letta.log import get_logger
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
5
9
|
import numpy as np
|
|
6
10
|
from sqlalchemy import Select, and_, asc, desc, func, literal, nulls_last, or_, select, union_all
|
|
7
11
|
from sqlalchemy.orm import noload
|
|
@@ -38,7 +42,7 @@ from letta.schemas.embedding_config import EmbeddingConfig
|
|
|
38
42
|
from letta.schemas.enums import AgentType, MessageRole
|
|
39
43
|
from letta.schemas.letta_message_content import TextContent
|
|
40
44
|
from letta.schemas.memory import Memory
|
|
41
|
-
from letta.schemas.message import Message, MessageCreate
|
|
45
|
+
from letta.schemas.message import Message, MessageCreate, ToolReturn
|
|
42
46
|
from letta.schemas.tool_rule import ToolRule
|
|
43
47
|
from letta.schemas.user import User
|
|
44
48
|
from letta.settings import DatabaseChoice, settings
|
|
@@ -256,6 +260,7 @@ def compile_system_message(
|
|
|
256
260
|
tool_rules_solver: Optional[ToolRulesSolver] = None,
|
|
257
261
|
sources: Optional[List] = None,
|
|
258
262
|
max_files_open: Optional[int] = None,
|
|
263
|
+
llm_config: Optional[object] = None,
|
|
259
264
|
) -> str:
|
|
260
265
|
"""Prepare the final/full system message that will be fed into the LLM API
|
|
261
266
|
|
|
@@ -289,7 +294,7 @@ def compile_system_message(
|
|
|
289
294
|
)
|
|
290
295
|
|
|
291
296
|
memory_with_sources = in_context_memory.compile(
|
|
292
|
-
tool_usage_rules=tool_constraint_block, sources=sources, max_files_open=max_files_open
|
|
297
|
+
tool_usage_rules=tool_constraint_block, sources=sources, max_files_open=max_files_open, llm_config=llm_config
|
|
293
298
|
)
|
|
294
299
|
full_memory_string = memory_with_sources + "\n\n" + memory_metadata_string
|
|
295
300
|
|
|
@@ -303,7 +308,7 @@ def compile_system_message(
|
|
|
303
308
|
if append_icm_if_missing:
|
|
304
309
|
if memory_variable_string not in system_prompt:
|
|
305
310
|
# In this case, append it to the end to make sure memory is still injected
|
|
306
|
-
#
|
|
311
|
+
# logger.warning(f"{IN_CONTEXT_MEMORY_KEYWORD} variable was missing from system prompt, appending instead")
|
|
307
312
|
system_prompt += "\n\n" + memory_variable_string
|
|
308
313
|
|
|
309
314
|
# render the variables using the built-in templater
|
|
@@ -536,6 +541,13 @@ def package_initial_message_sequence(
|
|
|
536
541
|
agent_id=agent_id,
|
|
537
542
|
model=model,
|
|
538
543
|
tool_call_id=tool_call_id,
|
|
544
|
+
tool_returns=[
|
|
545
|
+
ToolReturn(
|
|
546
|
+
tool_call_id=tool_call_id,
|
|
547
|
+
status="success",
|
|
548
|
+
func_response=function_response,
|
|
549
|
+
)
|
|
550
|
+
],
|
|
539
551
|
)
|
|
540
552
|
)
|
|
541
553
|
else:
|
|
@@ -767,24 +779,66 @@ def _apply_filters(
|
|
|
767
779
|
return query
|
|
768
780
|
|
|
769
781
|
|
|
770
|
-
def _apply_relationship_filters(
|
|
771
|
-
|
|
782
|
+
def _apply_relationship_filters(
|
|
783
|
+
query,
|
|
784
|
+
include_relationships: Optional[List[str]] = None,
|
|
785
|
+
include: Optional[List[str]] = None,
|
|
786
|
+
):
|
|
787
|
+
# legacy include_relationships
|
|
788
|
+
if include_relationships is None and not include:
|
|
772
789
|
return query
|
|
773
790
|
|
|
774
|
-
|
|
775
|
-
query = query.options(noload(AgentModel.core_memory), noload(AgentModel.file_agents))
|
|
776
|
-
if "identity_ids" not in include_relationships:
|
|
777
|
-
query = query.options(noload(AgentModel.identities))
|
|
791
|
+
column_names = get_column_names_from_includes_params(include_relationships, include)
|
|
778
792
|
|
|
779
|
-
relationships = [
|
|
793
|
+
relationships = [
|
|
794
|
+
"core_memory",
|
|
795
|
+
"file_agents",
|
|
796
|
+
"identities",
|
|
797
|
+
"tool_exec_environment_variables",
|
|
798
|
+
"tools",
|
|
799
|
+
"sources",
|
|
800
|
+
"tags",
|
|
801
|
+
"multi_agent_group",
|
|
802
|
+
]
|
|
780
803
|
|
|
781
804
|
for rel in relationships:
|
|
782
|
-
if rel not in
|
|
805
|
+
if rel not in column_names:
|
|
783
806
|
query = query.options(noload(getattr(AgentModel, rel)))
|
|
784
807
|
|
|
785
808
|
return query
|
|
786
809
|
|
|
787
810
|
|
|
811
|
+
def get_column_names_from_includes_params(
|
|
812
|
+
include_relationships: Optional[List[str]] = None, includes: Optional[List[str]] = None
|
|
813
|
+
) -> Set[str]:
|
|
814
|
+
include_mapping = {
|
|
815
|
+
"agent.blocks": ["core_memory"],
|
|
816
|
+
"agent.identities": ["identities"],
|
|
817
|
+
"agent.managed_group": ["multi_agent_group"],
|
|
818
|
+
"agent.secrets": ["tool_exec_environment_variables"],
|
|
819
|
+
"agent.sources": ["sources"],
|
|
820
|
+
"agent.tags": ["tags"],
|
|
821
|
+
"agent.tools": ["tools"],
|
|
822
|
+
# legacy
|
|
823
|
+
"memory": ["core_memory", "file_agents"],
|
|
824
|
+
"identity_ids": ["identities"],
|
|
825
|
+
"multi_agent_group": ["multi_agent_group"],
|
|
826
|
+
"tool_exec_environment_variables": ["tool_exec_environment_variables"],
|
|
827
|
+
"secrets": ["tool_exec_environment_variables"],
|
|
828
|
+
"sources": ["sources"],
|
|
829
|
+
"tags": ["tags"],
|
|
830
|
+
"tools": ["tools"],
|
|
831
|
+
}
|
|
832
|
+
column_names = set()
|
|
833
|
+
if includes:
|
|
834
|
+
for include in includes:
|
|
835
|
+
column_names.update(include_mapping.get(include, []))
|
|
836
|
+
else:
|
|
837
|
+
for include_relationship in include_relationships:
|
|
838
|
+
column_names.update(include_mapping.get(include_relationship, []))
|
|
839
|
+
return column_names
|
|
840
|
+
|
|
841
|
+
|
|
788
842
|
async def build_passage_query(
|
|
789
843
|
actor: User,
|
|
790
844
|
agent_id: Optional[str] = None,
|
|
@@ -12,6 +12,7 @@ from letta.orm.identity import Identity as IdentityModel
|
|
|
12
12
|
from letta.otel.tracing import trace_method
|
|
13
13
|
from letta.schemas.agent import AgentState
|
|
14
14
|
from letta.schemas.block import Block
|
|
15
|
+
from letta.schemas.enums import PrimitiveType
|
|
15
16
|
from letta.schemas.identity import (
|
|
16
17
|
Identity as PydanticIdentity,
|
|
17
18
|
IdentityCreate,
|
|
@@ -24,6 +25,7 @@ from letta.schemas.user import User as PydanticUser
|
|
|
24
25
|
from letta.server.db import db_registry
|
|
25
26
|
from letta.settings import DatabaseChoice, settings
|
|
26
27
|
from letta.utils import enforce_types
|
|
28
|
+
from letta.validators import raise_on_invalid_id
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
class IdentityManager:
|
|
@@ -40,7 +42,13 @@ class IdentityManager:
|
|
|
40
42
|
limit: Optional[int] = 50,
|
|
41
43
|
ascending: bool = False,
|
|
42
44
|
actor: PydanticUser = None,
|
|
43
|
-
) -> list[PydanticIdentity]:
|
|
45
|
+
) -> tuple[list[PydanticIdentity], Optional[str], bool]:
|
|
46
|
+
"""
|
|
47
|
+
List identities with pagination metadata.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Tuple of (identities, next_cursor, has_more)
|
|
51
|
+
"""
|
|
44
52
|
async with db_registry.async_session() as session:
|
|
45
53
|
filters = {"organization_id": actor.organization_id}
|
|
46
54
|
if project_id:
|
|
@@ -49,19 +57,34 @@ class IdentityManager:
|
|
|
49
57
|
filters["identifier_key"] = identifier_key
|
|
50
58
|
if identity_type:
|
|
51
59
|
filters["identity_type"] = identity_type
|
|
60
|
+
|
|
61
|
+
# Request one more than limit to check if there are more pages
|
|
62
|
+
query_limit = limit + 1 if limit else None
|
|
63
|
+
|
|
52
64
|
identities = await IdentityModel.list_async(
|
|
53
65
|
db_session=session,
|
|
54
66
|
query_text=name,
|
|
55
67
|
before=before,
|
|
56
68
|
after=after,
|
|
57
|
-
limit=
|
|
69
|
+
limit=query_limit,
|
|
58
70
|
ascending=ascending,
|
|
59
71
|
**filters,
|
|
60
72
|
)
|
|
61
|
-
|
|
73
|
+
|
|
74
|
+
# Check if we got more records than requested (meaning there are more pages)
|
|
75
|
+
has_more = len(identities) > limit if limit else False
|
|
76
|
+
if has_more:
|
|
77
|
+
# Trim back to the requested limit
|
|
78
|
+
identities = identities[:limit]
|
|
79
|
+
|
|
80
|
+
# Get cursor for next page (ID of last item in current page)
|
|
81
|
+
next_cursor = identities[-1].id if identities else None
|
|
82
|
+
|
|
83
|
+
return [identity.to_pydantic() for identity in identities], next_cursor, has_more
|
|
62
84
|
|
|
63
85
|
@enforce_types
|
|
64
86
|
@trace_method
|
|
87
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
65
88
|
async def get_identity_async(self, identity_id: str, actor: PydanticUser) -> PydanticIdentity:
|
|
66
89
|
async with db_registry.async_session() as session:
|
|
67
90
|
identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
|
|
@@ -143,6 +166,7 @@ class IdentityManager:
|
|
|
143
166
|
|
|
144
167
|
@enforce_types
|
|
145
168
|
@trace_method
|
|
169
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
146
170
|
async def update_identity_async(
|
|
147
171
|
self, identity_id: str, identity: IdentityUpdate, actor: PydanticUser, replace: bool = False
|
|
148
172
|
) -> PydanticIdentity:
|
|
@@ -206,6 +230,7 @@ class IdentityManager:
|
|
|
206
230
|
|
|
207
231
|
@enforce_types
|
|
208
232
|
@trace_method
|
|
233
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
209
234
|
async def upsert_identity_properties_async(
|
|
210
235
|
self, identity_id: str, properties: List[IdentityProperty], actor: PydanticUser
|
|
211
236
|
) -> PydanticIdentity:
|
|
@@ -223,6 +248,7 @@ class IdentityManager:
|
|
|
223
248
|
|
|
224
249
|
@enforce_types
|
|
225
250
|
@trace_method
|
|
251
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
226
252
|
async def delete_identity_async(self, identity_id: str, actor: PydanticUser) -> None:
|
|
227
253
|
async with db_registry.async_session() as session:
|
|
228
254
|
identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
|
|
@@ -280,6 +306,7 @@ class IdentityManager:
|
|
|
280
306
|
|
|
281
307
|
@enforce_types
|
|
282
308
|
@trace_method
|
|
309
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
283
310
|
async def list_agents_for_identity_async(
|
|
284
311
|
self,
|
|
285
312
|
identity_id: str,
|
|
@@ -287,6 +314,7 @@ class IdentityManager:
|
|
|
287
314
|
after: Optional[str] = None,
|
|
288
315
|
limit: Optional[int] = 50,
|
|
289
316
|
ascending: bool = False,
|
|
317
|
+
include: List[str] = [],
|
|
290
318
|
actor: PydanticUser = None,
|
|
291
319
|
) -> List[AgentState]:
|
|
292
320
|
"""
|
|
@@ -307,10 +335,11 @@ class IdentityManager:
|
|
|
307
335
|
ascending=ascending,
|
|
308
336
|
identity_id=identity.id,
|
|
309
337
|
)
|
|
310
|
-
return await asyncio.gather(*[agent.to_pydantic_async() for agent in agents])
|
|
338
|
+
return await asyncio.gather(*[agent.to_pydantic_async(include_relationships=[], include=include) for agent in agents])
|
|
311
339
|
|
|
312
340
|
@enforce_types
|
|
313
341
|
@trace_method
|
|
342
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
314
343
|
async def list_blocks_for_identity_async(
|
|
315
344
|
self,
|
|
316
345
|
identity_id: str,
|
|
@@ -339,3 +368,75 @@ class IdentityManager:
|
|
|
339
368
|
identity_id=identity.id,
|
|
340
369
|
)
|
|
341
370
|
return [block.to_pydantic() for block in blocks]
|
|
371
|
+
|
|
372
|
+
@enforce_types
|
|
373
|
+
@trace_method
|
|
374
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
375
|
+
@raise_on_invalid_id(param_name="agent_id", expected_prefix=PrimitiveType.AGENT)
|
|
376
|
+
async def attach_agent_async(self, identity_id: str, agent_id: str, actor: PydanticUser) -> None:
|
|
377
|
+
"""
|
|
378
|
+
Attach an agent to an identity.
|
|
379
|
+
"""
|
|
380
|
+
async with db_registry.async_session() as session:
|
|
381
|
+
identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
|
|
382
|
+
|
|
383
|
+
agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
|
|
384
|
+
|
|
385
|
+
# Add agent to identity if not already attached
|
|
386
|
+
if agent not in identity.agents:
|
|
387
|
+
identity.agents.append(agent)
|
|
388
|
+
await identity.update_async(db_session=session, actor=actor)
|
|
389
|
+
|
|
390
|
+
@enforce_types
|
|
391
|
+
@trace_method
|
|
392
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
393
|
+
@raise_on_invalid_id(param_name="agent_id", expected_prefix=PrimitiveType.AGENT)
|
|
394
|
+
async def detach_agent_async(self, identity_id: str, agent_id: str, actor: PydanticUser) -> None:
|
|
395
|
+
"""
|
|
396
|
+
Detach an agent from an identity.
|
|
397
|
+
"""
|
|
398
|
+
async with db_registry.async_session() as session:
|
|
399
|
+
identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
|
|
400
|
+
|
|
401
|
+
agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
|
|
402
|
+
|
|
403
|
+
# Remove agent from identity if attached
|
|
404
|
+
if agent in identity.agents:
|
|
405
|
+
identity.agents.remove(agent)
|
|
406
|
+
await identity.update_async(db_session=session, actor=actor)
|
|
407
|
+
|
|
408
|
+
@enforce_types
|
|
409
|
+
@trace_method
|
|
410
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
411
|
+
@raise_on_invalid_id(param_name="block_id", expected_prefix=PrimitiveType.BLOCK)
|
|
412
|
+
async def attach_block_async(self, identity_id: str, block_id: str, actor: PydanticUser) -> None:
|
|
413
|
+
"""
|
|
414
|
+
Attach a block to an identity.
|
|
415
|
+
"""
|
|
416
|
+
async with db_registry.async_session() as session:
|
|
417
|
+
identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
|
|
418
|
+
|
|
419
|
+
block = await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
|
|
420
|
+
|
|
421
|
+
# Add block to identity if not already attached
|
|
422
|
+
if block not in identity.blocks:
|
|
423
|
+
identity.blocks.append(block)
|
|
424
|
+
await identity.update_async(db_session=session, actor=actor)
|
|
425
|
+
|
|
426
|
+
@enforce_types
|
|
427
|
+
@trace_method
|
|
428
|
+
@raise_on_invalid_id(param_name="identity_id", expected_prefix=PrimitiveType.IDENTITY)
|
|
429
|
+
@raise_on_invalid_id(param_name="block_id", expected_prefix=PrimitiveType.BLOCK)
|
|
430
|
+
async def detach_block_async(self, identity_id: str, block_id: str, actor: PydanticUser) -> None:
|
|
431
|
+
"""
|
|
432
|
+
Detach a block from an identity.
|
|
433
|
+
"""
|
|
434
|
+
async with db_registry.async_session() as session:
|
|
435
|
+
identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
|
|
436
|
+
|
|
437
|
+
block = await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
|
|
438
|
+
|
|
439
|
+
# Remove block from identity if attached
|
|
440
|
+
if block in identity.blocks:
|
|
441
|
+
identity.blocks.remove(block)
|
|
442
|
+
await identity.update_async(db_session=session, actor=actor)
|
letta/services/job_manager.py
CHANGED
|
@@ -14,7 +14,7 @@ from letta.orm.message import Message as MessageModel
|
|
|
14
14
|
from letta.orm.sqlalchemy_base import AccessType
|
|
15
15
|
from letta.orm.step import Step, Step as StepModel
|
|
16
16
|
from letta.otel.tracing import log_event, trace_method
|
|
17
|
-
from letta.schemas.enums import JobStatus, JobType, MessageRole
|
|
17
|
+
from letta.schemas.enums import JobStatus, JobType, MessageRole, PrimitiveType
|
|
18
18
|
from letta.schemas.job import BatchJob as PydanticBatchJob, Job as PydanticJob, JobUpdate, LettaRequestConfig
|
|
19
19
|
from letta.schemas.letta_message import LettaMessage
|
|
20
20
|
from letta.schemas.letta_stop_reason import StopReasonType
|
|
@@ -26,6 +26,7 @@ from letta.schemas.user import User as PydanticUser
|
|
|
26
26
|
from letta.server.db import db_registry
|
|
27
27
|
from letta.services.helpers.agent_manager_helper import validate_agent_exists_async
|
|
28
28
|
from letta.utils import enforce_types
|
|
29
|
+
from letta.validators import raise_on_invalid_id
|
|
29
30
|
|
|
30
31
|
logger = get_logger(__name__)
|
|
31
32
|
|
|
@@ -70,6 +71,7 @@ class JobManager:
|
|
|
70
71
|
|
|
71
72
|
@enforce_types
|
|
72
73
|
@trace_method
|
|
74
|
+
@raise_on_invalid_id(param_name="job_id", expected_prefix=PrimitiveType.JOB)
|
|
73
75
|
async def update_job_by_id_async(
|
|
74
76
|
self, job_id: str, job_update: JobUpdate, actor: PydanticUser, safe_update: bool = False
|
|
75
77
|
) -> PydanticJob:
|
|
@@ -147,6 +149,7 @@ class JobManager:
|
|
|
147
149
|
|
|
148
150
|
@enforce_types
|
|
149
151
|
@trace_method
|
|
152
|
+
@raise_on_invalid_id(param_name="job_id", expected_prefix=PrimitiveType.JOB)
|
|
150
153
|
async def safe_update_job_status_async(
|
|
151
154
|
self,
|
|
152
155
|
job_id: str,
|
|
@@ -187,6 +190,7 @@ class JobManager:
|
|
|
187
190
|
|
|
188
191
|
@enforce_types
|
|
189
192
|
@trace_method
|
|
193
|
+
@raise_on_invalid_id(param_name="job_id", expected_prefix=PrimitiveType.JOB)
|
|
190
194
|
async def get_job_by_id_async(self, job_id: str, actor: PydanticUser) -> PydanticJob:
|
|
191
195
|
"""Fetch a job by its ID asynchronously."""
|
|
192
196
|
async with db_registry.async_session() as session:
|
|
@@ -301,6 +305,7 @@ class JobManager:
|
|
|
301
305
|
|
|
302
306
|
@enforce_types
|
|
303
307
|
@trace_method
|
|
308
|
+
@raise_on_invalid_id(param_name="job_id", expected_prefix=PrimitiveType.JOB)
|
|
304
309
|
async def delete_job_by_id_async(self, job_id: str, actor: PydanticUser) -> PydanticJob:
|
|
305
310
|
"""Delete a job by its ID."""
|
|
306
311
|
async with db_registry.async_session() as session:
|
|
@@ -310,6 +315,7 @@ class JobManager:
|
|
|
310
315
|
|
|
311
316
|
@enforce_types
|
|
312
317
|
@trace_method
|
|
318
|
+
@raise_on_invalid_id(param_name="run_id", expected_prefix=PrimitiveType.RUN)
|
|
313
319
|
async def get_run_messages(
|
|
314
320
|
self,
|
|
315
321
|
run_id: str,
|
|
@@ -367,6 +373,7 @@ class JobManager:
|
|
|
367
373
|
|
|
368
374
|
@enforce_types
|
|
369
375
|
@trace_method
|
|
376
|
+
@raise_on_invalid_id(param_name="run_id", expected_prefix=PrimitiveType.RUN)
|
|
370
377
|
async def get_step_messages(
|
|
371
378
|
self,
|
|
372
379
|
run_id: str,
|
|
@@ -447,6 +454,7 @@ class JobManager:
|
|
|
447
454
|
return job
|
|
448
455
|
|
|
449
456
|
@enforce_types
|
|
457
|
+
@raise_on_invalid_id(param_name="job_id", expected_prefix=PrimitiveType.JOB)
|
|
450
458
|
async def record_ttft(self, job_id: str, ttft_ns: int, actor: PydanticUser) -> None:
|
|
451
459
|
"""Record time to first token for a run"""
|
|
452
460
|
try:
|
|
@@ -459,6 +467,7 @@ class JobManager:
|
|
|
459
467
|
logger.warning(f"Failed to record TTFT for job {job_id}: {e}")
|
|
460
468
|
|
|
461
469
|
@enforce_types
|
|
470
|
+
@raise_on_invalid_id(param_name="job_id", expected_prefix=PrimitiveType.JOB)
|
|
462
471
|
async def record_response_duration(self, job_id: str, total_duration_ns: int, actor: PydanticUser) -> None:
|
|
463
472
|
"""Record total response duration for a run"""
|
|
464
473
|
try:
|
|
@@ -529,6 +538,7 @@ class JobManager:
|
|
|
529
538
|
|
|
530
539
|
@enforce_types
|
|
531
540
|
@trace_method
|
|
541
|
+
@raise_on_invalid_id(param_name="job_id", expected_prefix=PrimitiveType.JOB)
|
|
532
542
|
async def get_job_steps(
|
|
533
543
|
self,
|
|
534
544
|
job_id: str,
|
|
@@ -83,10 +83,10 @@ class AsyncBaseMCPClient:
|
|
|
83
83
|
for content_piece in result.content:
|
|
84
84
|
if isinstance(content_piece, TextContent):
|
|
85
85
|
parsed_content.append(content_piece.text)
|
|
86
|
-
|
|
86
|
+
logger.debug(f"MCP tool result parsed content (text): {parsed_content}")
|
|
87
87
|
else:
|
|
88
88
|
parsed_content.append(str(content_piece))
|
|
89
|
-
|
|
89
|
+
logger.debug(f"MCP tool result parsed content (other): {parsed_content}")
|
|
90
90
|
if len(parsed_content) > 0:
|
|
91
91
|
final_content = " ".join(parsed_content)
|
|
92
92
|
else:
|
|
@@ -34,12 +34,21 @@ class DatabaseTokenStorage(TokenStorage):
|
|
|
34
34
|
async def get_tokens(self) -> Optional[OAuthToken]:
|
|
35
35
|
"""Retrieve tokens from database."""
|
|
36
36
|
oauth_session = await self.mcp_manager.get_oauth_session_by_id(self.session_id, self.actor)
|
|
37
|
-
if not oauth_session
|
|
37
|
+
if not oauth_session:
|
|
38
38
|
return None
|
|
39
39
|
|
|
40
|
+
# Decrypt tokens using getter methods
|
|
41
|
+
access_token_secret = oauth_session.get_access_token_secret()
|
|
42
|
+
access_token = access_token_secret.get_plaintext()
|
|
43
|
+
if not access_token:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
refresh_token_secret = oauth_session.get_refresh_token_secret()
|
|
47
|
+
refresh_token = refresh_token_secret.get_plaintext()
|
|
48
|
+
|
|
40
49
|
return OAuthToken(
|
|
41
|
-
access_token=
|
|
42
|
-
refresh_token=
|
|
50
|
+
access_token=access_token,
|
|
51
|
+
refresh_token=refresh_token,
|
|
43
52
|
token_type=oauth_session.token_type,
|
|
44
53
|
expires_in=int(oauth_session.expires_at.timestamp() - time.time()),
|
|
45
54
|
scope=oauth_session.scope,
|
|
@@ -63,9 +72,13 @@ class DatabaseTokenStorage(TokenStorage):
|
|
|
63
72
|
if not oauth_session or not oauth_session.client_id:
|
|
64
73
|
return None
|
|
65
74
|
|
|
75
|
+
# Decrypt client secret using getter method
|
|
76
|
+
client_secret_secret = oauth_session.get_client_secret_secret()
|
|
77
|
+
client_secret = client_secret_secret.get_plaintext()
|
|
78
|
+
|
|
66
79
|
return OAuthClientInformationFull(
|
|
67
80
|
client_id=oauth_session.client_id,
|
|
68
|
-
client_secret=
|
|
81
|
+
client_secret=client_secret,
|
|
69
82
|
redirect_uris=[oauth_session.redirect_uri] if oauth_session.redirect_uri else [],
|
|
70
83
|
)
|
|
71
84
|
|
|
@@ -134,13 +147,23 @@ class MCPOAuthSession:
|
|
|
134
147
|
|
|
135
148
|
async def store_authorization_code(self, code: str, state: str) -> Optional[MCPOAuth]:
|
|
136
149
|
"""Store the authorization code from OAuth callback."""
|
|
150
|
+
# Use mcp_manager to ensure proper encryption
|
|
151
|
+
from letta.schemas.mcp import MCPOAuthSessionUpdate
|
|
152
|
+
from letta.schemas.secret import Secret
|
|
153
|
+
|
|
137
154
|
async with db_registry.async_session() as session:
|
|
138
155
|
try:
|
|
139
156
|
oauth_record = await MCPOAuth.read_async(db_session=session, identifier=self.session_id, actor=None)
|
|
140
|
-
|
|
141
|
-
|
|
157
|
+
|
|
158
|
+
# Encrypt the authorization_code before storing
|
|
159
|
+
if code is not None:
|
|
160
|
+
oauth_record.authorization_code_enc = Secret.from_plaintext(code).get_encrypted()
|
|
161
|
+
# Keep plaintext for dual-write during migration
|
|
162
|
+
oauth_record.authorization_code = code
|
|
163
|
+
|
|
142
164
|
oauth_record.status = OAuthSessionStatus.AUTHORIZED
|
|
143
|
-
oauth_record.
|
|
165
|
+
oauth_record.state = state
|
|
166
|
+
|
|
144
167
|
return await oauth_record.update_async(db_session=session, actor=None)
|
|
145
168
|
except Exception:
|
|
146
169
|
return None
|
|
@@ -212,7 +235,9 @@ async def create_oauth_provider(
|
|
|
212
235
|
while time.time() - start_time < timeout:
|
|
213
236
|
oauth_session = await mcp_manager.get_oauth_session_by_id(session_id, actor)
|
|
214
237
|
if oauth_session and oauth_session.authorization_code:
|
|
215
|
-
|
|
238
|
+
# Decrypt the authorization code before returning
|
|
239
|
+
auth_code_secret = oauth_session.get_authorization_code_secret()
|
|
240
|
+
return auth_code_secret.get_plaintext(), oauth_session.state
|
|
216
241
|
elif oauth_session and oauth_session.status == OAuthSessionStatus.ERROR:
|
|
217
242
|
raise Exception("OAuth authorization failed")
|
|
218
243
|
await asyncio.sleep(1)
|