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

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

Potentially problematic release.


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

Files changed (159) hide show
  1. letta/__init__.py +2 -3
  2. letta/adapters/letta_llm_adapter.py +1 -0
  3. letta/adapters/simple_llm_request_adapter.py +8 -5
  4. letta/adapters/simple_llm_stream_adapter.py +22 -6
  5. letta/agents/agent_loop.py +10 -3
  6. letta/agents/base_agent.py +4 -1
  7. letta/agents/helpers.py +41 -9
  8. letta/agents/letta_agent.py +11 -10
  9. letta/agents/letta_agent_v2.py +47 -37
  10. letta/agents/letta_agent_v3.py +395 -300
  11. letta/agents/voice_agent.py +8 -6
  12. letta/agents/voice_sleeptime_agent.py +3 -3
  13. letta/constants.py +30 -7
  14. letta/errors.py +20 -0
  15. letta/functions/function_sets/base.py +55 -3
  16. letta/functions/mcp_client/types.py +33 -57
  17. letta/functions/schema_generator.py +135 -23
  18. letta/groups/sleeptime_multi_agent_v3.py +6 -11
  19. letta/groups/sleeptime_multi_agent_v4.py +227 -0
  20. letta/helpers/converters.py +78 -4
  21. letta/helpers/crypto_utils.py +6 -2
  22. letta/interfaces/anthropic_parallel_tool_call_streaming_interface.py +9 -11
  23. letta/interfaces/anthropic_streaming_interface.py +3 -4
  24. letta/interfaces/gemini_streaming_interface.py +4 -6
  25. letta/interfaces/openai_streaming_interface.py +63 -28
  26. letta/llm_api/anthropic_client.py +7 -4
  27. letta/llm_api/deepseek_client.py +6 -4
  28. letta/llm_api/google_ai_client.py +3 -12
  29. letta/llm_api/google_vertex_client.py +1 -1
  30. letta/llm_api/helpers.py +90 -61
  31. letta/llm_api/llm_api_tools.py +4 -1
  32. letta/llm_api/openai.py +12 -12
  33. letta/llm_api/openai_client.py +53 -16
  34. letta/local_llm/constants.py +4 -3
  35. letta/local_llm/json_parser.py +5 -2
  36. letta/local_llm/utils.py +2 -3
  37. letta/log.py +171 -7
  38. letta/orm/agent.py +43 -9
  39. letta/orm/archive.py +4 -0
  40. letta/orm/custom_columns.py +15 -0
  41. letta/orm/identity.py +11 -11
  42. letta/orm/mcp_server.py +9 -0
  43. letta/orm/message.py +6 -1
  44. letta/orm/run_metrics.py +7 -2
  45. letta/orm/sqlalchemy_base.py +2 -2
  46. letta/orm/tool.py +3 -0
  47. letta/otel/tracing.py +2 -0
  48. letta/prompts/prompt_generator.py +7 -2
  49. letta/schemas/agent.py +41 -10
  50. letta/schemas/agent_file.py +3 -0
  51. letta/schemas/archive.py +4 -2
  52. letta/schemas/block.py +2 -1
  53. letta/schemas/enums.py +36 -3
  54. letta/schemas/file.py +3 -3
  55. letta/schemas/folder.py +2 -1
  56. letta/schemas/group.py +2 -1
  57. letta/schemas/identity.py +18 -9
  58. letta/schemas/job.py +3 -1
  59. letta/schemas/letta_message.py +71 -12
  60. letta/schemas/letta_request.py +7 -3
  61. letta/schemas/letta_stop_reason.py +0 -25
  62. letta/schemas/llm_config.py +8 -2
  63. letta/schemas/mcp.py +80 -83
  64. letta/schemas/mcp_server.py +349 -0
  65. letta/schemas/memory.py +20 -8
  66. letta/schemas/message.py +212 -67
  67. letta/schemas/providers/anthropic.py +13 -6
  68. letta/schemas/providers/azure.py +6 -4
  69. letta/schemas/providers/base.py +8 -4
  70. letta/schemas/providers/bedrock.py +6 -2
  71. letta/schemas/providers/cerebras.py +7 -3
  72. letta/schemas/providers/deepseek.py +2 -1
  73. letta/schemas/providers/google_gemini.py +15 -6
  74. letta/schemas/providers/groq.py +2 -1
  75. letta/schemas/providers/lmstudio.py +9 -6
  76. letta/schemas/providers/mistral.py +2 -1
  77. letta/schemas/providers/openai.py +7 -2
  78. letta/schemas/providers/together.py +9 -3
  79. letta/schemas/providers/xai.py +7 -3
  80. letta/schemas/run.py +7 -2
  81. letta/schemas/run_metrics.py +2 -1
  82. letta/schemas/sandbox_config.py +2 -2
  83. letta/schemas/secret.py +3 -158
  84. letta/schemas/source.py +2 -2
  85. letta/schemas/step.py +2 -2
  86. letta/schemas/tool.py +24 -1
  87. letta/schemas/usage.py +0 -1
  88. letta/server/rest_api/app.py +123 -7
  89. letta/server/rest_api/dependencies.py +3 -0
  90. letta/server/rest_api/interface.py +7 -4
  91. letta/server/rest_api/redis_stream_manager.py +16 -1
  92. letta/server/rest_api/routers/v1/__init__.py +7 -0
  93. letta/server/rest_api/routers/v1/agents.py +332 -322
  94. letta/server/rest_api/routers/v1/archives.py +127 -40
  95. letta/server/rest_api/routers/v1/blocks.py +54 -6
  96. letta/server/rest_api/routers/v1/chat_completions.py +146 -0
  97. letta/server/rest_api/routers/v1/folders.py +27 -35
  98. letta/server/rest_api/routers/v1/groups.py +23 -35
  99. letta/server/rest_api/routers/v1/identities.py +24 -10
  100. letta/server/rest_api/routers/v1/internal_runs.py +107 -0
  101. letta/server/rest_api/routers/v1/internal_templates.py +162 -179
  102. letta/server/rest_api/routers/v1/jobs.py +15 -27
  103. letta/server/rest_api/routers/v1/mcp_servers.py +309 -0
  104. letta/server/rest_api/routers/v1/messages.py +23 -34
  105. letta/server/rest_api/routers/v1/organizations.py +6 -27
  106. letta/server/rest_api/routers/v1/providers.py +35 -62
  107. letta/server/rest_api/routers/v1/runs.py +30 -43
  108. letta/server/rest_api/routers/v1/sandbox_configs.py +6 -4
  109. letta/server/rest_api/routers/v1/sources.py +26 -42
  110. letta/server/rest_api/routers/v1/steps.py +16 -29
  111. letta/server/rest_api/routers/v1/tools.py +17 -13
  112. letta/server/rest_api/routers/v1/users.py +5 -17
  113. letta/server/rest_api/routers/v1/voice.py +18 -27
  114. letta/server/rest_api/streaming_response.py +5 -2
  115. letta/server/rest_api/utils.py +187 -25
  116. letta/server/server.py +27 -22
  117. letta/server/ws_api/server.py +5 -4
  118. letta/services/agent_manager.py +148 -26
  119. letta/services/agent_serialization_manager.py +6 -1
  120. letta/services/archive_manager.py +168 -15
  121. letta/services/block_manager.py +14 -4
  122. letta/services/file_manager.py +33 -29
  123. letta/services/group_manager.py +10 -0
  124. letta/services/helpers/agent_manager_helper.py +65 -11
  125. letta/services/identity_manager.py +105 -4
  126. letta/services/job_manager.py +11 -1
  127. letta/services/mcp/base_client.py +2 -2
  128. letta/services/mcp/oauth_utils.py +33 -8
  129. letta/services/mcp_manager.py +174 -78
  130. letta/services/mcp_server_manager.py +1331 -0
  131. letta/services/message_manager.py +109 -4
  132. letta/services/organization_manager.py +4 -4
  133. letta/services/passage_manager.py +9 -25
  134. letta/services/provider_manager.py +91 -15
  135. letta/services/run_manager.py +72 -15
  136. letta/services/sandbox_config_manager.py +45 -3
  137. letta/services/source_manager.py +15 -8
  138. letta/services/step_manager.py +24 -1
  139. letta/services/streaming_service.py +581 -0
  140. letta/services/summarizer/summarizer.py +1 -1
  141. letta/services/tool_executor/core_tool_executor.py +111 -0
  142. letta/services/tool_executor/files_tool_executor.py +5 -3
  143. letta/services/tool_executor/sandbox_tool_executor.py +2 -2
  144. letta/services/tool_executor/tool_execution_manager.py +1 -1
  145. letta/services/tool_manager.py +10 -3
  146. letta/services/tool_sandbox/base.py +61 -1
  147. letta/services/tool_sandbox/local_sandbox.py +1 -3
  148. letta/services/user_manager.py +2 -2
  149. letta/settings.py +49 -5
  150. letta/system.py +14 -5
  151. letta/utils.py +73 -1
  152. letta/validators.py +105 -0
  153. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/METADATA +4 -2
  154. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/RECORD +157 -151
  155. letta/schemas/letta_ping.py +0 -28
  156. letta/server/rest_api/routers/openai/chat_completions/__init__.py +0 -0
  157. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/WHEEL +0 -0
  158. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/entry_points.txt +0 -0
  159. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/licenses/LICENSE +0 -0
@@ -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
- # warnings.warn(f"{IN_CONTEXT_MEMORY_KEYWORD} variable was missing from system prompt, appending instead")
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(query, include_relationships: Optional[List[str]] = None):
771
- if include_relationships is None:
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
- if "memory" not in include_relationships:
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 = ["tool_exec_environment_variables", "tools", "sources", "tags", "multi_agent_group"]
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 include_relationships:
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=limit,
69
+ limit=query_limit,
58
70
  ascending=ascending,
59
71
  **filters,
60
72
  )
61
- return [identity.to_pydantic() for identity in identities]
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)
@@ -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
- print("parsed_content (text)", parsed_content)
86
+ logger.debug(f"MCP tool result parsed content (text): {parsed_content}")
87
87
  else:
88
88
  parsed_content.append(str(content_piece))
89
- print("parsed_content (other)", parsed_content)
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 or not oauth_session.access_token:
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=oauth_session.access_token,
42
- refresh_token=oauth_session.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=oauth_session.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
- oauth_record.authorization_code = code
141
- oauth_record.state = state
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.updated_at = datetime.now()
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
- return oauth_session.authorization_code, oauth_session.state
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)