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.

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.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.dist-info}/METADATA +4 -2
  154. {letta_nightly-0.12.1.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.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.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.dist-info}/WHEEL +0 -0
  158. {letta_nightly-0.12.1.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.dist-info}/entry_points.txt +0 -0
  159. {letta_nightly-0.12.1.dev20251024104217.dist-info → letta_nightly-0.13.0.dev20251025104015.dist-info}/licenses/LICENSE +0 -0
@@ -47,6 +47,7 @@ class LettaCoreToolExecutor(ToolExecutor):
47
47
  "core_memory_replace": self.core_memory_replace,
48
48
  "memory_replace": self.memory_replace,
49
49
  "memory_insert": self.memory_insert,
50
+ "memory_apply_patch": self.memory_apply_patch,
50
51
  "memory_str_replace": self.memory_str_replace,
51
52
  "memory_str_insert": self.memory_str_insert,
52
53
  "memory_rethink": self.memory_rethink,
@@ -393,6 +394,116 @@ class LettaCoreToolExecutor(ToolExecutor):
393
394
  # return None
394
395
  return success_msg
395
396
 
397
+ async def memory_apply_patch(self, agent_state: AgentState, actor: User, label: str, patch: str) -> str:
398
+ """Apply a simplified unified-diff style patch to a memory block, anchored on content and context.
399
+
400
+ Args:
401
+ label: The memory block label to modify.
402
+ patch: Patch text with lines starting with " ", "-", or "+" and optional "@@" hunk headers.
403
+
404
+ Returns:
405
+ Success message on clean application; raises ValueError on mismatch/ambiguity.
406
+ """
407
+ if agent_state.memory.get_block(label).read_only:
408
+ raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}")
409
+
410
+ # Guardrails: forbid visual line numbers and warning banners
411
+ if MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(patch or ""):
412
+ raise ValueError(
413
+ "Patch contains a line number prefix, which is not allowed. Do not include line numbers (they are for display only)."
414
+ )
415
+ if CORE_MEMORY_LINE_NUMBER_WARNING in (patch or ""):
416
+ raise ValueError("Patch contains the line number warning banner, which is not allowed. Provide only the text to edit.")
417
+
418
+ current_value = str(agent_state.memory.get_block(label).value).expandtabs()
419
+ patch = str(patch).expandtabs()
420
+
421
+ current_lines = current_value.split("\n")
422
+ # Ignore common diff headers
423
+ raw_lines = patch.splitlines()
424
+ patch_lines = [ln for ln in raw_lines if not ln.startswith("*** ") and not ln.startswith("---") and not ln.startswith("+++")]
425
+
426
+ # Split into hunks using '@@' as delimiter
427
+ hunks: list[list[str]] = []
428
+ h: list[str] = []
429
+ for ln in patch_lines:
430
+ if ln.startswith("@@"):
431
+ if h:
432
+ hunks.append(h)
433
+ h = []
434
+ continue
435
+ if ln.startswith(" ") or ln.startswith("-") or ln.startswith("+"):
436
+ h.append(ln)
437
+ elif ln.strip() == "":
438
+ # Treat blank line as context for empty string line
439
+ h.append(" ")
440
+ else:
441
+ # Skip unknown metadata lines
442
+ continue
443
+ if h:
444
+ hunks.append(h)
445
+
446
+ if not hunks:
447
+ raise ValueError("No applicable hunks found in patch. Ensure lines start with ' ', '-', or '+'.")
448
+
449
+ def find_all_subseq(hay: list[str], needle: list[str]) -> list[int]:
450
+ out: list[int] = []
451
+ n = len(needle)
452
+ if n == 0:
453
+ return out
454
+ for i in range(0, len(hay) - n + 1):
455
+ if hay[i : i + n] == needle:
456
+ out.append(i)
457
+ return out
458
+
459
+ # Apply each hunk sequentially against the rolling buffer
460
+ for hunk in hunks:
461
+ expected: list[str] = []
462
+ replacement: list[str] = []
463
+ for ln in hunk:
464
+ if ln.startswith(" "):
465
+ line = ln[1:]
466
+ expected.append(line)
467
+ replacement.append(line)
468
+ elif ln.startswith("-"):
469
+ line = ln[1:]
470
+ expected.append(line)
471
+ elif ln.startswith("+"):
472
+ line = ln[1:]
473
+ replacement.append(line)
474
+
475
+ if not expected and replacement:
476
+ # Pure insertion with no context: append at end
477
+ current_lines = current_lines + replacement
478
+ continue
479
+
480
+ matches = find_all_subseq(current_lines, expected)
481
+ if len(matches) == 0:
482
+ sample = "\n".join(expected[:4])
483
+ raise ValueError(
484
+ "Failed to apply patch: expected hunk context not found in the memory block. "
485
+ f"Verify the target lines exist and try providing more context. Expected start:\n{sample}"
486
+ )
487
+ if len(matches) > 1:
488
+ raise ValueError(
489
+ "Failed to apply patch: hunk context matched multiple places in the memory block. "
490
+ "Please add more unique surrounding context to disambiguate."
491
+ )
492
+
493
+ idx = matches[0]
494
+ end = idx + len(expected)
495
+ current_lines = current_lines[:idx] + replacement + current_lines[end:]
496
+
497
+ new_value = "\n".join(current_lines)
498
+ agent_state.memory.update_block_value(label=label, value=new_value)
499
+ await self.agent_manager.update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor)
500
+
501
+ return (
502
+ f"The core memory block with label `{label}` has been edited. "
503
+ "Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). "
504
+ "Edit the memory block again if necessary."
505
+ )
506
+
396
507
  async def memory_insert(
397
508
  self,
398
509
  agent_state: AgentState,
@@ -2,6 +2,8 @@ import asyncio
2
2
  import re
3
3
  from typing import Any, Dict, List, Optional
4
4
 
5
+ from sqlalchemy.exc import NoResultFound
6
+
5
7
  from letta.constants import PINECONE_TEXT_FIELD_NAME
6
8
  from letta.functions.types import FileOpenRequest
7
9
  from letta.helpers.pinecone_utils import search_pinecone_index, should_use_pinecone
@@ -389,9 +391,9 @@ class LettaFileToolExecutor(ToolExecutor):
389
391
 
390
392
  for file_agent in file_agents:
391
393
  # Load file content
392
- file = await self.file_manager.get_file_by_id(file_id=file_agent.file_id, actor=self.actor, include_content=True)
393
-
394
- if not file or not file.content:
394
+ try:
395
+ file = await self.file_manager.get_file_by_id(file_id=file_agent.file_id, actor=self.actor, include_content=True)
396
+ except NoResultFound:
395
397
  files_skipped += 1
396
398
  self.logger.warning(f"Grep: Skipping file {file_agent.file_name} - no content available")
397
399
  continue
@@ -36,7 +36,7 @@ class SandboxToolExecutor(ToolExecutor):
36
36
  ) -> ToolExecutionResult:
37
37
  # Store original memory state
38
38
  if agent_state:
39
- orig_memory_str = agent_state.memory.compile()
39
+ orig_memory_str = agent_state.memory.compile(llm_config=agent_state.llm_config)
40
40
  else:
41
41
  orig_memory_str = None
42
42
 
@@ -89,7 +89,7 @@ class SandboxToolExecutor(ToolExecutor):
89
89
 
90
90
  # Verify memory integrity
91
91
  if agent_state:
92
- new_memory_str = agent_state.memory.compile()
92
+ new_memory_str = agent_state.memory.compile(llm_config=agent_state.llm_config)
93
93
  assert orig_memory_str == new_memory_str, "Memory should not be modified in a sandbox tool"
94
94
 
95
95
  # Update agent memory if needed
@@ -142,7 +142,7 @@ class ToolExecutionManager:
142
142
  )
143
143
  except Exception as e:
144
144
  status = "error"
145
- self.logger.error(f"Error executing tool {function_name}: {str(e)}")
145
+ self.logger.info(f"Error executing tool {function_name}: {str(e)}")
146
146
  error_message = get_friendly_error_msg(
147
147
  function_name=function_name,
148
148
  exception_name=type(e).__name__,
@@ -1,5 +1,4 @@
1
1
  import importlib
2
- import warnings
3
2
  from typing import List, Optional, Set, Union
4
3
 
5
4
  from sqlalchemy import and_, func, or_, select
@@ -13,6 +12,7 @@ from letta.constants import (
13
12
  BASE_VOICE_SLEEPTIME_TOOLS,
14
13
  BUILTIN_TOOLS,
15
14
  FILES_TOOLS,
15
+ LETTA_PARALLEL_SAFE_TOOLS,
16
16
  LETTA_TOOL_MODULE_NAMES,
17
17
  LETTA_TOOL_SET,
18
18
  LOCAL_ONLY_MULTI_AGENT_TOOLS,
@@ -26,7 +26,7 @@ from letta.log import get_logger
26
26
  from letta.orm.errors import NoResultFound
27
27
  from letta.orm.tool import Tool as ToolModel
28
28
  from letta.otel.tracing import trace_method
29
- from letta.schemas.enums import ToolType
29
+ from letta.schemas.enums import PrimitiveType, ToolType
30
30
  from letta.schemas.tool import Tool as PydanticTool, ToolCreate, ToolUpdate
31
31
  from letta.schemas.user import User as PydanticUser
32
32
  from letta.server.db import db_registry
@@ -35,6 +35,7 @@ from letta.services.mcp.types import SSEServerConfig, StdioServerConfig
35
35
  from letta.services.tool_schema_generator import generate_schema_for_tool_creation, generate_schema_for_tool_update
36
36
  from letta.settings import settings
37
37
  from letta.utils import enforce_types, printd
38
+ from letta.validators import raise_on_invalid_id
38
39
 
39
40
  logger = get_logger(__name__)
40
41
 
@@ -202,6 +203,7 @@ class ToolManager:
202
203
 
203
204
  @enforce_types
204
205
  @trace_method
206
+ @raise_on_invalid_id(param_name="tool_id", expected_prefix=PrimitiveType.TOOL)
205
207
  async def get_tool_by_id_async(self, tool_id: str, actor: PydanticUser) -> PydanticTool:
206
208
  """Fetch a tool by its ID."""
207
209
  async with db_registry.async_session() as session:
@@ -234,6 +236,7 @@ class ToolManager:
234
236
 
235
237
  @enforce_types
236
238
  @trace_method
239
+ @raise_on_invalid_id(param_name="tool_id", expected_prefix=PrimitiveType.TOOL)
237
240
  async def tool_exists_async(self, tool_id: str, actor: PydanticUser) -> bool:
238
241
  """Check if a tool exists and belongs to the user's organization (lightweight check)."""
239
242
  async with db_registry.async_session() as session:
@@ -506,6 +509,7 @@ class ToolManager:
506
509
 
507
510
  @enforce_types
508
511
  @trace_method
512
+ @raise_on_invalid_id(param_name="tool_id", expected_prefix=PrimitiveType.TOOL)
509
513
  async def update_tool_by_id_async(
510
514
  self,
511
515
  tool_id: str,
@@ -603,6 +607,7 @@ class ToolManager:
603
607
 
604
608
  @enforce_types
605
609
  @trace_method
610
+ # @raise_on_invalid_id This is commented out bc it's called by _list_tools_async, when it encounters malformed tools (i.e. if id is invalid will fail validation on deletion)
606
611
  async def delete_tool_by_id_async(self, tool_id: str, actor: PydanticUser) -> None:
607
612
  """Delete a tool by its ID."""
608
613
  async with db_registry.async_session() as session:
@@ -630,7 +635,7 @@ class ToolManager:
630
635
  module = importlib.import_module(module_name)
631
636
  functions_to_schema.update(load_function_set(module))
632
637
  except ValueError as e:
633
- warnings.warn(f"Error loading function set '{module_name}': {e}")
638
+ logger.warning(f"Error loading function set '{module_name}': {e}")
634
639
  except Exception as e:
635
640
  raise e
636
641
 
@@ -662,12 +667,14 @@ class ToolManager:
662
667
  continue
663
668
 
664
669
  # create pydantic tool for validation and conversion
670
+ parallel_safe = name in LETTA_PARALLEL_SAFE_TOOLS
665
671
  pydantic_tool = PydanticTool(
666
672
  name=name,
667
673
  tags=[tool_type.value],
668
674
  source_type="python",
669
675
  tool_type=tool_type,
670
676
  return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT,
677
+ enable_parallel_execution=parallel_safe,
671
678
  )
672
679
 
673
680
  # auto-generate description if not provided
@@ -57,11 +57,19 @@ class AsyncToolSandboxBase(ABC):
57
57
  f"Agent attempted to invoke tool {self.tool_name} that does not exist for organization {self.user.organization_id}"
58
58
  )
59
59
 
60
+ # Check for reserved keyword arguments
61
+ tool_arguments = parse_function_arguments(self.tool.source_code, self.tool.name)
62
+
60
63
  # TODO: deprecate this
61
- if "agent_state" in parse_function_arguments(self.tool.source_code, self.tool.name):
64
+ if "agent_state" in tool_arguments:
62
65
  self.inject_agent_state = True
63
66
  else:
64
67
  self.inject_agent_state = False
68
+
69
+ # Check for Letta client and agent_id injection
70
+ self.inject_letta_client = "letta_client" in tool_arguments or "client" in tool_arguments
71
+ self.inject_agent_id = "agent_id" in tool_arguments
72
+
65
73
  self.is_async_function = self._detect_async_function()
66
74
  self._initialized = True
67
75
 
@@ -112,12 +120,16 @@ class AsyncToolSandboxBase(ABC):
112
120
  tool_args += self.initialize_param(param, self.args[param])
113
121
 
114
122
  agent_state_pickle = pickle.dumps(agent_state) if self.inject_agent_state else None
123
+ agent_id = agent_state.id if agent_state else None
115
124
 
116
125
  code = self._render_sandbox_code(
117
126
  future_import=future_import,
118
127
  inject_agent_state=self.inject_agent_state,
128
+ inject_letta_client=self.inject_letta_client,
129
+ inject_agent_id=self.inject_agent_id,
119
130
  schema_imports=schema_code or "",
120
131
  agent_state_pickle=agent_state_pickle,
132
+ agent_id=agent_id,
121
133
  tool_args=tool_args,
122
134
  tool_source_code=self.tool.source_code,
123
135
  local_sandbox_result_var_name=self.LOCAL_SANDBOX_RESULT_VAR_NAME,
@@ -133,8 +145,11 @@ class AsyncToolSandboxBase(ABC):
133
145
  *,
134
146
  future_import: bool,
135
147
  inject_agent_state: bool,
148
+ inject_letta_client: bool,
149
+ inject_agent_id: bool,
136
150
  schema_imports: str,
137
151
  agent_state_pickle: bytes | None,
152
+ agent_id: str | None,
138
153
  tool_args: str,
139
154
  tool_source_code: str,
140
155
  local_sandbox_result_var_name: str,
@@ -162,6 +177,10 @@ class AsyncToolSandboxBase(ABC):
162
177
  if inject_agent_state:
163
178
  lines.extend(["import letta", "from letta import *"]) # noqa: F401
164
179
 
180
+ # Import Letta client if needed
181
+ if inject_letta_client:
182
+ lines.append("from letta_client import Letta")
183
+
165
184
  if schema_imports:
166
185
  lines.append(schema_imports.rstrip())
167
186
 
@@ -170,6 +189,34 @@ class AsyncToolSandboxBase(ABC):
170
189
  else:
171
190
  lines.append("agent_state = None")
172
191
 
192
+ # Initialize Letta client if needed
193
+ if inject_letta_client:
194
+ from letta.settings import settings
195
+
196
+ lines.extend(
197
+ [
198
+ "# Initialize Letta client for tool execution",
199
+ "letta_client = Letta(",
200
+ f" base_url={repr(settings.default_base_url)},",
201
+ f" token={repr(settings.default_token)}",
202
+ ")",
203
+ "# Compatibility shim for client.agents.get",
204
+ "try:",
205
+ " _agents = letta_client.agents",
206
+ " if not hasattr(_agents, 'get') and hasattr(_agents, 'retrieve'):",
207
+ " setattr(_agents, 'get', _agents.retrieve)",
208
+ "except Exception:",
209
+ " pass",
210
+ ]
211
+ )
212
+
213
+ # Set agent_id if needed
214
+ if inject_agent_id:
215
+ if agent_id:
216
+ lines.append(f"agent_id = {repr(agent_id)}")
217
+ else:
218
+ lines.append("agent_id = None")
219
+
173
220
  if tool_args:
174
221
  lines.append(tool_args.rstrip())
175
222
 
@@ -286,9 +333,22 @@ class AsyncToolSandboxBase(ABC):
286
333
  kwargs.append(name)
287
334
 
288
335
  param_list = [f"{arg}={arg}" for arg in kwargs]
336
+
337
+ # Add reserved keyword arguments
289
338
  if self.inject_agent_state:
290
339
  param_list.append("agent_state=agent_state")
291
340
 
341
+ if self.inject_letta_client:
342
+ # Check if the function expects 'client' or 'letta_client'
343
+ tool_arguments = parse_function_arguments(self.tool.source_code, self.tool.name)
344
+ if "client" in tool_arguments:
345
+ param_list.append("client=letta_client")
346
+ elif "letta_client" in tool_arguments:
347
+ param_list.append("letta_client=letta_client")
348
+
349
+ if self.inject_agent_id:
350
+ param_list.append("agent_id=agent_id")
351
+
292
352
  params = ", ".join(param_list)
293
353
  func_call_str = self.tool.name + "(" + params + ")"
294
354
  return func_call_str
@@ -235,9 +235,7 @@ class AsyncToolSandboxLocal(AsyncToolSandboxBase):
235
235
  if isinstance(e, TimeoutError):
236
236
  raise e
237
237
 
238
- logger.error(f"Subprocess execution for tool {self.tool_name} encountered an error: {e}")
239
- logger.error(e.__class__.__name__)
240
- logger.error(e.__traceback__)
238
+ logger.exception(f"Subprocess execution for tool {self.tool_name} encountered an error: {e}")
241
239
  func_return = get_friendly_error_msg(
242
240
  function_name=self.tool_name,
243
241
  exception_name=type(e).__name__,
@@ -58,7 +58,7 @@ class UserManager:
58
58
  @enforce_types
59
59
  @trace_method
60
60
  async def update_actor_async(self, user_update: UserUpdate) -> PydanticUser:
61
- """Update user details (async version)."""
61
+ """Update user details (async version). Raises NoResultFound if not found."""
62
62
  async with db_registry.async_session() as session:
63
63
  # Retrieve the existing user by ID
64
64
  existing_user = await UserModel.read_async(db_session=session, identifier=user_update.id)
@@ -76,7 +76,7 @@ class UserManager:
76
76
  @enforce_types
77
77
  @trace_method
78
78
  async def delete_actor_by_id_async(self, user_id: str):
79
- """Delete a user and their associated records (agents, sources, mappings) asynchronously."""
79
+ """Delete a user and their associated records (agents, sources, mappings) asynchronously. Raises NoResultFound if not found."""
80
80
  async with db_registry.async_session() as session:
81
81
  # Delete from user table
82
82
  user = await UserModel.read_async(db_session=session, identifier=user_id)
letta/settings.py CHANGED
@@ -6,10 +6,13 @@ from typing import Optional
6
6
  from pydantic import AliasChoices, Field
7
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
8
8
 
9
- from letta.local_llm.constants import DEFAULT_WRAPPER_NAME, INNER_THOUGHTS_KWARG
10
9
  from letta.schemas.enums import SandboxType
11
10
  from letta.services.summarizer.enums import SummarizationMode
12
11
 
12
+ # Define constants here to avoid circular import with letta.log
13
+ DEFAULT_WRAPPER_NAME = "chatml"
14
+ INNER_THOUGHTS_KWARG = "thinking"
15
+
13
16
 
14
17
  class ToolSettings(BaseSettings):
15
18
  # Sandbox Configurations
@@ -329,6 +332,10 @@ class Settings(BaseSettings):
329
332
  file_processing_timeout_minutes: int = 30
330
333
  file_processing_timeout_error_message: str = "File processing timed out after {} minutes. Please try again."
331
334
 
335
+ # Letta client settings for tool execution
336
+ default_base_url: str = Field(default="http://localhost:8283", description="Default base URL for Letta client in tool execution")
337
+ default_token: Optional[str] = Field(default=None, description="Default token for Letta client in tool execution")
338
+
332
339
  # enabling letta_agent_v1 architecture
333
340
  use_letta_v1_agent: bool = False
334
341
 
@@ -374,16 +381,53 @@ class TestSettings(Settings):
374
381
 
375
382
  class LogSettings(BaseSettings):
376
383
  model_config = SettingsConfigDict(env_prefix="letta_logging_", extra="ignore")
377
- debug: bool | None = Field(False, description="Enable debugging for logging")
378
- json_logging: bool = Field(False, description="Enable json logging instead of text logging")
384
+ debug: bool = Field(default=False, description="Enable debugging for logging")
385
+ json_logging: bool = Field(
386
+ default=False,
387
+ description="Enable structured JSON logging (recommended).",
388
+ )
379
389
  log_level: str | None = Field("WARNING", description="Logging level")
380
390
  letta_log_path: Path | None = Field(Path.home() / ".letta" / "logs" / "Letta.log")
381
- verbose_telemetry_logging: bool = Field(False)
391
+ verbose_telemetry_logging: bool = Field(default=False)
382
392
 
383
393
 
384
394
  class TelemetrySettings(BaseSettings):
395
+ """Configuration for telemetry and observability integrations."""
396
+
385
397
  model_config = SettingsConfigDict(env_prefix="letta_telemetry_", extra="ignore")
386
- profiler: bool | None = Field(False, description="Enable use of the profiler.")
398
+
399
+ # Google Cloud Profiler
400
+ profiler: bool = Field(default=False, description="Enable Google Cloud Profiler.")
401
+
402
+ # Datadog APM and Profiling
403
+ enable_datadog: bool = Field(default=False, description="Enable Datadog profiling. Environment is pulled from settings.environment.")
404
+ datadog_agent_host: str = Field(
405
+ default="localhost",
406
+ description="Datadog agent hostname or IP address. Use service name for Kubernetes (e.g., 'datadog-cluster-agent').",
407
+ )
408
+ datadog_agent_port: int = Field(default=8126, ge=1, le=65535, description="Datadog trace agent port (typically 8126 for traces).")
409
+ datadog_service_name: str = Field(default="letta-server", description="Service name for Datadog profiling.")
410
+ datadog_profiling_memory_enabled: bool = Field(default=False, description="Enable memory profiling in Datadog.")
411
+ datadog_profiling_heap_enabled: bool = Field(default=False, description="Enable heap profiling in Datadog.")
412
+
413
+ # Datadog Source Code Integration (optional, tightly coupled with profiling)
414
+ # These settings link profiling data and traces to specific Git commits,
415
+ # enabling code navigation directly from Datadog UI to GitHub/GitLab.
416
+ datadog_git_repository_url: str | None = Field(
417
+ default=None,
418
+ validation_alias=AliasChoices("DD_GIT_REPOSITORY_URL", "datadog_git_repository_url"),
419
+ description="Git repository URL (e.g., 'https://github.com/org/repo'). Set at build time.",
420
+ )
421
+ datadog_git_commit_sha: str | None = Field(
422
+ default=None,
423
+ validation_alias=AliasChoices("DD_GIT_COMMIT_SHA", "datadog_git_commit_sha"),
424
+ description="Git commit SHA for the deployed code. Set at build time with 'git rev-parse HEAD'.",
425
+ )
426
+ datadog_main_package: str = Field(
427
+ default="letta",
428
+ validation_alias=AliasChoices("DD_MAIN_PACKAGE", "datadog_main_package"),
429
+ description="Primary Python package name for source code linking. Datadog uses this setting to determine which code is 'yours' vs. third-party dependencies.",
430
+ )
387
431
 
388
432
 
389
433
  # singleton
letta/system.py CHANGED
@@ -1,7 +1,10 @@
1
1
  import json
2
- import warnings
3
2
  from typing import Optional
4
3
 
4
+ from letta.log import get_logger
5
+
6
+ logger = get_logger(__name__)
7
+
5
8
  from .constants import (
6
9
  INITIAL_BOOT_MESSAGE,
7
10
  INITIAL_BOOT_MESSAGE_SEND_MESSAGE_FIRST_MSG,
@@ -42,11 +45,17 @@ def get_initial_boot_messages(version, timezone, tool_call_id):
42
45
  },
43
46
  # obligatory function return message
44
47
  {
45
- # "role": "function",
46
48
  "role": "tool",
47
49
  "name": "send_message", # NOTE: technically not up to spec, this is old functions style
48
50
  "content": package_function_response(True, None, timezone),
49
51
  "tool_call_id": tool_call_id,
52
+ "tool_returns": [
53
+ {
54
+ "tool_call_id": tool_call_id,
55
+ "status": "success",
56
+ "func_response": package_function_response(True, None, timezone),
57
+ }
58
+ ],
50
59
  },
51
60
  ]
52
61
 
@@ -154,7 +163,7 @@ def package_system_message(system_message, timezone, message_type="system_alert"
154
163
  try:
155
164
  message_json = json.loads(system_message)
156
165
  if "type" in message_json and message_json["type"] == message_type:
157
- warnings.warn(f"Attempted to pack a system message that is already packed. Not packing: '{system_message}'")
166
+ logger.warning(f"Attempted to pack a system message that is already packed. Not packing: '{system_message}'")
158
167
  return system_message
159
168
  except:
160
169
  pass # do nothing, expected behavior that the message is not JSON
@@ -245,7 +254,7 @@ def unpack_message(packed_message: str) -> str:
245
254
  if "type" in message_json and message_json["type"] in ["login", "heartbeat"]:
246
255
  # This is a valid user message that the ADE expects, so don't print warning
247
256
  return packed_message
248
- warnings.warn(f"Was unable to find 'message' field in packed message object: '{packed_message}'")
257
+ logger.warning(f"Was unable to find 'message' field in packed message object: '{packed_message}'")
249
258
  return packed_message
250
259
  else:
251
260
  try:
@@ -254,6 +263,6 @@ def unpack_message(packed_message: str) -> str:
254
263
  return packed_message
255
264
 
256
265
  if message_type != "user_message":
257
- warnings.warn(f"Expected type to be 'user_message', but was '{message_type}', so not unpacking: '{packed_message}'")
266
+ logger.warning(f"Expected type to be 'user_message', but was '{message_type}', so not unpacking: '{packed_message}'")
258
267
  return packed_message
259
268
  return message_json.get("message")
letta/utils.py CHANGED
@@ -41,6 +41,7 @@ from letta.helpers.json_helpers import json_dumps, json_loads
41
41
  from letta.log import get_logger
42
42
  from letta.otel.tracing import log_attributes, trace_method
43
43
  from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
44
+ from letta.server.rest_api.dependencies import HeaderParams
44
45
 
45
46
  logger = get_logger(__name__)
46
47
 
@@ -1129,6 +1130,29 @@ def safe_create_task(coro, label: str = "background task"):
1129
1130
  return task
1130
1131
 
1131
1132
 
1133
+ @trace_method
1134
+ def safe_create_task_with_return(coro, label: str = "background task"):
1135
+ async def wrapper():
1136
+ try:
1137
+ return await coro
1138
+ except Exception as e:
1139
+ logger.exception(f"{label} failed with {type(e).__name__}: {e}")
1140
+ raise
1141
+
1142
+ task = asyncio.create_task(wrapper())
1143
+
1144
+ # Add task to the set to maintain strong reference
1145
+ _background_tasks.add(task)
1146
+
1147
+ # Log task count to trace
1148
+ log_attributes({"total_background_task_count": get_background_task_count()})
1149
+
1150
+ # Remove task from set when done to prevent memory leaks
1151
+ task.add_done_callback(_background_tasks.discard)
1152
+
1153
+ return task
1154
+
1155
+
1132
1156
  def safe_create_shielded_task(coro, label: str = "shielded background task"):
1133
1157
  """
1134
1158
  Create a shielded background task that cannot be cancelled externally.
@@ -1367,7 +1391,7 @@ def fire_and_forget(coro, task_name: Optional[str] = None, error_callback: Optio
1367
1391
  t.result() # this re-raises exceptions from the task
1368
1392
  except Exception as e:
1369
1393
  task_desc = f"Background task {task_name}" if task_name else "Background task"
1370
- logger.error(f"{task_desc} failed: {str(e)}\n{traceback.format_exc()}")
1394
+ logger.exception(f"{task_desc} failed: {str(e)}")
1371
1395
 
1372
1396
  if error_callback:
1373
1397
  try:
@@ -1377,3 +1401,51 @@ def fire_and_forget(coro, task_name: Optional[str] = None, error_callback: Optio
1377
1401
 
1378
1402
  task.add_done_callback(callback)
1379
1403
  return task
1404
+
1405
+
1406
+ def is_1_0_sdk_version(headers: HeaderParams):
1407
+ """
1408
+ Check if the SDK version is 1.0.0 or above.
1409
+ 1. If sdk_version is provided from stainless (all stainless versions are 1.0.0+)
1410
+ 2. If user_agent is provided and in the format
1411
+ @letta-ai/letta-client/version (node) or
1412
+ letta-client/version (python)
1413
+ """
1414
+ sdk_version = headers.sdk_version
1415
+ if sdk_version:
1416
+ return True
1417
+
1418
+ client = headers.user_agent
1419
+ if "/" not in client:
1420
+ return False
1421
+
1422
+ # Split into parts to validate format
1423
+ parts = client.split("/")
1424
+
1425
+ # Should have at least 2 parts (client-name/version)
1426
+ if len(parts) < 2:
1427
+ return False
1428
+
1429
+ if len(parts) == 3:
1430
+ # Format: @letta-ai/letta-client/version
1431
+ if parts[0] != "@letta-ai" or parts[1] != "letta-client":
1432
+ return False
1433
+ elif len(parts) == 2:
1434
+ # Format: letta-client/version
1435
+ if parts[0] != "letta-client":
1436
+ return False
1437
+ else:
1438
+ return False
1439
+
1440
+ # Extract and validate version
1441
+ maybe_version = parts[-1]
1442
+ if "." not in maybe_version:
1443
+ return False
1444
+
1445
+ # Extract major version (handle alpha/beta suffixes like 1.0.0-alpha.2 or 1.0.0a5)
1446
+ version_base = maybe_version.split("-")[0].split("a")[0].split("b")[0]
1447
+ if "." not in version_base:
1448
+ return False
1449
+
1450
+ major_version = version_base.split(".")[0]
1451
+ return major_version == "1"