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
@@ -36,9 +36,9 @@ from letta.server.rest_api.utils import (
36
36
  )
37
37
  from letta.services.agent_manager import AgentManager
38
38
  from letta.services.block_manager import BlockManager
39
- from letta.services.job_manager import JobManager
40
39
  from letta.services.message_manager import MessageManager
41
40
  from letta.services.passage_manager import PassageManager
41
+ from letta.services.run_manager import RunManager
42
42
  from letta.services.summarizer.enums import SummarizationMode
43
43
  from letta.services.summarizer.summarizer import Summarizer
44
44
  from letta.services.tool_executor.tool_execution_manager import ToolExecutionManager
@@ -63,7 +63,7 @@ class VoiceAgent(BaseAgent):
63
63
  message_manager: MessageManager,
64
64
  agent_manager: AgentManager,
65
65
  block_manager: BlockManager,
66
- job_manager: JobManager,
66
+ run_manager: RunManager,
67
67
  passage_manager: PassageManager,
68
68
  actor: User,
69
69
  ):
@@ -73,7 +73,7 @@ class VoiceAgent(BaseAgent):
73
73
 
74
74
  # Summarizer settings
75
75
  self.block_manager = block_manager
76
- self.job_manager = job_manager
76
+ self.run_manager = run_manager
77
77
  self.passage_manager = passage_manager
78
78
  # TODO: This is not guaranteed to exist!
79
79
  self.summary_block_label = "human"
@@ -99,7 +99,7 @@ class VoiceAgent(BaseAgent):
99
99
  agent_manager=self.agent_manager,
100
100
  actor=self.actor,
101
101
  block_manager=self.block_manager,
102
- job_manager=self.job_manager,
102
+ run_manager=self.run_manager,
103
103
  passage_manager=self.passage_manager,
104
104
  target_block_label=self.summary_block_label,
105
105
  ),
@@ -153,6 +153,7 @@ class VoiceAgent(BaseAgent):
153
153
  archival_memory_size=self.num_archival_memories,
154
154
  sources=agent_state.sources,
155
155
  max_files_open=agent_state.max_files_open,
156
+ llm_config=agent_state.llm_config,
156
157
  )
157
158
  letta_message_db_queue = create_input_messages(
158
159
  input_messages=input_messages, agent_id=agent_state.id, timezone=agent_state.timezone, actor=self.actor
@@ -437,13 +438,14 @@ class VoiceAgent(BaseAgent):
437
438
  )
438
439
 
439
440
  # Use ToolExecutionManager for modern tool execution
440
- sandbox_env_vars = {var.key: var.value for var in agent_state.secrets}
441
+ # Decrypt environment variable values
442
+ sandbox_env_vars = {var.key: var.get_value_secret().get_plaintext() for var in agent_state.secrets}
441
443
  tool_execution_manager = ToolExecutionManager(
442
444
  agent_state=agent_state,
443
445
  message_manager=self.message_manager,
444
446
  agent_manager=self.agent_manager,
445
447
  block_manager=self.block_manager,
446
- job_manager=self.job_manager,
448
+ run_manager=self.run_manager,
447
449
  passage_manager=self.passage_manager,
448
450
  sandbox_env_vars=sandbox_env_vars,
449
451
  actor=self.actor,
@@ -14,9 +14,9 @@ from letta.schemas.tool_rule import ChildToolRule, ContinueToolRule, InitToolRul
14
14
  from letta.schemas.user import User
15
15
  from letta.services.agent_manager import AgentManager
16
16
  from letta.services.block_manager import BlockManager
17
- from letta.services.job_manager import JobManager
18
17
  from letta.services.message_manager import MessageManager
19
18
  from letta.services.passage_manager import PassageManager
19
+ from letta.services.run_manager import RunManager
20
20
  from letta.services.summarizer.enums import SummarizationMode
21
21
  from letta.services.summarizer.summarizer import Summarizer
22
22
  from letta.types import JsonDict
@@ -34,7 +34,7 @@ class VoiceSleeptimeAgent(LettaAgent):
34
34
  message_manager: MessageManager,
35
35
  agent_manager: AgentManager,
36
36
  block_manager: BlockManager,
37
- job_manager: JobManager,
37
+ run_manager: RunManager,
38
38
  passage_manager: PassageManager,
39
39
  target_block_label: str,
40
40
  actor: User,
@@ -44,7 +44,7 @@ class VoiceSleeptimeAgent(LettaAgent):
44
44
  message_manager=message_manager,
45
45
  agent_manager=agent_manager,
46
46
  block_manager=block_manager,
47
- job_manager=job_manager,
47
+ job_manager=run_manager,
48
48
  passage_manager=passage_manager,
49
49
  actor=actor,
50
50
  )
letta/constants.py CHANGED
@@ -33,8 +33,6 @@ LETTA_TOOL_MODULE_NAMES = [
33
33
  DEFAULT_ORG_ID = "org-00000000-0000-4000-8000-000000000000"
34
34
  DEFAULT_ORG_NAME = "default_org"
35
35
 
36
- AGENT_ID_PATTERN = re.compile(r"^agent-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.IGNORECASE)
37
-
38
36
  # String in the error message for when the context window is too large
39
37
  # Example full message:
40
38
  # This model's maximum context length is 8192 tokens. However, your messages resulted in 8198 tokens (7450 in the messages, 748 in the functions). Please reduce the length of the messages or functions.
@@ -127,10 +125,10 @@ LOCAL_ONLY_MULTI_AGENT_TOOLS = ["send_message_to_agent_async"]
127
125
 
128
126
  # Used to catch if line numbers are pushed in
129
127
  # MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX = re.compile(r"^Line \d+: ", re.MULTILINE)
130
- # More "robust" version that handles different kinds of whitespace
128
+ # Updated to match new arrow format: "1→ content"
131
129
  # shared constant for both memory_insert and memory_replace
132
130
  MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX = re.compile(
133
- r"^[ \t]*Line[ \t]+\d+[ \t]*:", # allow any leading whitespace and flexible spacing
131
+ r"^[ \t]*\d+→[ \t]*", # match number followed by arrow, with optional whitespace
134
132
  re.MULTILINE,
135
133
  )
136
134
 
@@ -157,6 +155,16 @@ LETTA_TOOL_SET = set(
157
155
  + FILES_TOOLS
158
156
  )
159
157
 
158
+ LETTA_PARALLEL_SAFE_TOOLS = {
159
+ "conversation_search",
160
+ "archival_memory_search",
161
+ "run_code",
162
+ "web_search",
163
+ "fetch_webpage",
164
+ "grep_files",
165
+ "semantic_search_files",
166
+ }
167
+
160
168
 
161
169
  def FUNCTION_RETURN_VALUE_TRUNCATED(return_str, return_char: int, return_char_limit: int):
162
170
  return (
@@ -202,9 +210,7 @@ ERROR_MESSAGE_PREFIX = "Error"
202
210
 
203
211
  NON_USER_MSG_PREFIX = "[This is an automated system message hidden from the user] "
204
212
 
205
- CORE_MEMORY_LINE_NUMBER_WARNING = (
206
- "# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls."
207
- )
213
+ CORE_MEMORY_LINE_NUMBER_WARNING = "# NOTE: Line numbers shown below (with arrows like '1→') are to help during editing. Do NOT include line number prefixes in your memory edit tool calls."
208
214
 
209
215
 
210
216
  # Constants to do with summarization / conversation length window
@@ -318,6 +324,23 @@ LLM_MAX_TOKENS = {
318
324
  "gemini-2.0-flash-thinking-exp-1219": 1048576,
319
325
  "gemini-2.5-flash-preview-tts": 32768,
320
326
  "gemini-2.5-pro-preview-tts": 65536,
327
+ # gemini 2.5 stable releases
328
+ "gemini-2.5-flash": 1048576,
329
+ "gemini-2.5-flash-lite": 1048576,
330
+ "gemini-2.5-pro": 1048576,
331
+ "gemini-2.5-pro-preview-06-05": 1048576,
332
+ "gemini-2.5-flash-lite-preview-06-17": 1048576,
333
+ "gemini-2.5-flash-image": 1048576,
334
+ "gemini-2.5-flash-image-preview": 1048576,
335
+ "gemini-2.5-flash-preview-09-2025": 1048576,
336
+ "gemini-2.5-flash-lite-preview-09-2025": 1048576,
337
+ "gemini-2.5-computer-use-preview-10-2025": 1048576,
338
+ # gemini latest aliases
339
+ "gemini-flash-latest": 1048576,
340
+ "gemini-flash-lite-latest": 1048576,
341
+ "gemini-pro-latest": 1048576,
342
+ # gemini specialized models
343
+ "gemini-robotics-er-1.5-preview": 1048576,
321
344
  }
322
345
  # The error message that Letta will receive
323
346
  # MESSAGE_SUMMARY_WARNING_STR = f"Warning: the conversation history will soon reach its maximum length and be trimmed. Make sure to save any important information from the conversation to your memory before it is removed."
letta/errors.py CHANGED
@@ -19,6 +19,7 @@ class ErrorCode(Enum):
19
19
  RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
20
20
  TIMEOUT = "TIMEOUT"
21
21
  CONFLICT = "CONFLICT"
22
+ EXPIRED = "EXPIRED"
22
23
 
23
24
 
24
25
  class LettaError(Exception):
@@ -97,6 +98,10 @@ class LettaUserNotFoundError(LettaError):
97
98
  """Error raised when a user is not found."""
98
99
 
99
100
 
101
+ class LettaUnsupportedFileUploadError(LettaError):
102
+ """Error raised when an unsupported file upload is attempted."""
103
+
104
+
100
105
  class LettaInvalidArgumentError(LettaError):
101
106
  """Error raised when an invalid argument is provided."""
102
107
 
@@ -137,10 +142,25 @@ class LettaMCPTimeoutError(LettaMCPError):
137
142
  super().__init__(message=message, code=ErrorCode.TIMEOUT, details=details)
138
143
 
139
144
 
145
+ class LettaServiceUnavailableError(LettaError):
146
+ """Error raised when a required service is unavailable."""
147
+
148
+ def __init__(self, message: str, service_name: Optional[str] = None):
149
+ details = {"service_name": service_name} if service_name else {}
150
+ super().__init__(message=message, code=ErrorCode.INTERNAL_SERVER_ERROR, details=details)
151
+
152
+
140
153
  class LettaUnexpectedStreamCancellationError(LettaError):
141
154
  """Error raised when a streaming request is terminated unexpectedly."""
142
155
 
143
156
 
157
+ class LettaExpiredError(LettaError):
158
+ """Error raised when a resource has expired."""
159
+
160
+ def __init__(self, message: str):
161
+ super().__init__(message=message, code=ErrorCode.EXPIRED)
162
+
163
+
144
164
  class LLMError(LettaError):
145
165
  pass
146
166
 
@@ -294,6 +294,7 @@ SNIPPET_LINES: int = 4
294
294
  def memory_replace(agent_state: "AgentState", label: str, old_str: str, new_str: str) -> str: # type: ignore
295
295
  """
296
296
  The memory_replace command allows you to replace a specific string in a memory block with a new string. This is used for making precise edits.
297
+ Do NOT attempt to replace long strings, e.g. do not attempt to replace the entire contents of a memory block with a new string.
297
298
 
298
299
  Args:
299
300
  label (str): Section of the memory to be edited, identified by its label.
@@ -311,10 +312,10 @@ def memory_replace(agent_state: "AgentState", label: str, old_str: str, new_str:
311
312
  memory_replace(label="human", old_str="Their name is Alice", new_str="")
312
313
 
313
314
  # Bad example - do NOT add (view-only) line numbers to the args
314
- memory_replace(label="human", old_str="Line 1: Their name is Alice", new_str="Line 1: Their name is Bob")
315
+ memory_replace(label="human", old_str="1: Their name is Alice", new_str="1: Their name is Bob")
315
316
 
316
- # Bad example - do NOT include the number number warning either
317
- memory_replace(label="human", old_str="# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\\nLine 1: Their name is Alice", new_str="Line 1: Their name is Bob")
317
+ # Bad example - do NOT include the line number warning either
318
+ memory_replace(label="human", old_str="# NOTE: Line numbers shown below (with arrows like '1→') are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\\n1→ Their name is Alice", new_str="1 Their name is Bob")
318
319
 
319
320
  # Good example - no line numbers or line number warning (they are view-only), just the text
320
321
  memory_replace(label="human", old_str="Their name is Alice", new_str="Their name is Bob")
@@ -449,6 +450,57 @@ def memory_insert(agent_state: "AgentState", label: str, new_str: str, insert_li
449
450
  return success_msg
450
451
 
451
452
 
453
+ def memory_apply_patch(agent_state: "AgentState", label: str, patch: str) -> str: # type: ignore
454
+ """
455
+ Apply a unified-diff style patch to a memory block by anchoring on content and context (not line numbers).
456
+
457
+ The patch format is a simplified unified diff that supports one or more hunks. Each hunk may optionally
458
+ start with a line beginning with `@@` and then contains lines that begin with one of:
459
+ - " " (space): context lines that must match the current memory content
460
+ - "-": lines to remove (must match exactly in the current content)
461
+ - "+": lines to add
462
+
463
+ Notes:
464
+ - Do not include line number prefixes like "Line 12:" anywhere in the patch. Line numbers are for display only.
465
+ - Do not include the line-number warning banner. Provide only the text to edit.
466
+ - Tabs are normalized to spaces for matching consistency.
467
+
468
+ Args:
469
+ label (str): The memory block to edit, identified by its label.
470
+ patch (str): The simplified unified-diff patch text composed of context (" "), deletion ("-"), and addition ("+") lines. Optional
471
+ lines beginning with "@@" can be used to delimit hunks. Do not include visual line numbers or warning banners.
472
+
473
+ Examples:
474
+ Simple replacement:
475
+ label="human",
476
+ patch:
477
+ @@
478
+ -Their name is Alice
479
+ +Their name is Bob
480
+
481
+ Replacement with surrounding context for disambiguation:
482
+ label="persona",
483
+ patch:
484
+ @@
485
+ Persona:
486
+ -Friendly and curious
487
+ +Friendly, curious, and precise
488
+ Likes: Hiking
489
+
490
+ Insertion (no deletions) between two context lines:
491
+ label="todos",
492
+ patch:
493
+ @@
494
+ - [ ] Step 1: Gather requirements
495
+ + [ ] Step 1.5: Clarify stakeholders
496
+ - [ ] Step 2: Draft design
497
+
498
+ Returns:
499
+ str: A success message if the patch applied cleanly; raises ValueError otherwise.
500
+ """
501
+ raise NotImplementedError("This should never be invoked directly. Contact Letta if you see this error message.")
502
+
503
+
452
504
  def memory_rethink(agent_state: "AgentState", label: str, new_memory: str) -> None:
453
505
  """
454
506
  The memory_rethink command allows you to completely rewrite the contents of a memory block. Use this tool to make large sweeping changes (e.g. when you want to condense or reorganize the memory blocks), do NOT use this tool to make small precise edits (e.g. add or remove a line, replace a specific string, etc).
@@ -129,9 +129,9 @@ class BaseServerConfig(BaseModel):
129
129
  raise NotImplementedError
130
130
 
131
131
 
132
- class SSEServerConfig(BaseServerConfig):
132
+ class HTTPBasedServerConfig(BaseServerConfig):
133
133
  """
134
- Configuration for an MCP server using SSE
134
+ Base configuration for HTTP-based MCP servers (SSE and Streamable HTTP).
135
135
 
136
136
  Authentication can be provided in multiple ways:
137
137
  1. Using auth_header + auth_token: Will add a specific header with the token
@@ -141,11 +141,10 @@ class SSEServerConfig(BaseServerConfig):
141
141
  Example: custom_headers={"X-API-Key": "abc123", "X-Custom-Header": "value"}
142
142
  """
143
143
 
144
- type: MCPServerType = MCPServerType.SSE
145
- server_url: str = Field(..., description="The URL of the server (MCP SSE client will connect to this URL)")
144
+ server_url: str = Field(..., description="The URL of the server")
146
145
  auth_header: Optional[str] = Field(None, description="The name of the authentication header (e.g., 'Authorization')")
147
146
  auth_token: Optional[str] = Field(None, description="The authentication token or API key value")
148
- custom_headers: Optional[dict[str, str]] = Field(None, description="Custom HTTP headers to include with SSE requests")
147
+ custom_headers: Optional[dict[str, str]] = Field(None, description="Custom HTTP headers to include with requests")
149
148
 
150
149
  def resolve_token(self) -> Optional[str]:
151
150
  """
@@ -170,13 +169,13 @@ class SSEServerConfig(BaseServerConfig):
170
169
 
171
170
  self.custom_headers = super().resolve_custom_headers(self.custom_headers, environment_variables)
172
171
 
173
- def to_dict(self) -> dict:
174
- values = {
175
- "transport": "sse",
176
- "url": self.server_url,
177
- }
172
+ def _build_headers_dict(self) -> Optional[dict[str, str]]:
173
+ """
174
+ Build headers dictionary from custom_headers and auth_header/auth_token.
178
175
 
179
- # TODO: handle custom headers
176
+ Returns:
177
+ Dictionary of headers or None if no headers are configured
178
+ """
180
179
  if self.custom_headers is not None or (self.auth_header is not None and self.auth_token is not None):
181
180
  headers = self.custom_headers.copy() if self.custom_headers else {}
182
181
 
@@ -184,6 +183,24 @@ class SSEServerConfig(BaseServerConfig):
184
183
  if self.auth_header is not None and self.auth_token is not None:
185
184
  headers[self.auth_header] = self.auth_token
186
185
 
186
+ return headers
187
+ return None
188
+
189
+
190
+ class SSEServerConfig(HTTPBasedServerConfig):
191
+ """Configuration for an MCP server using SSE"""
192
+
193
+ type: MCPServerType = MCPServerType.SSE
194
+
195
+ def to_dict(self) -> dict:
196
+ values = {
197
+ "transport": "sse",
198
+ "url": self.server_url,
199
+ }
200
+
201
+ # Handle custom headers using shared method
202
+ headers = self._build_headers_dict()
203
+ if headers:
187
204
  values["headers"] = headers
188
205
 
189
206
  return values
@@ -210,46 +227,10 @@ class StdioServerConfig(BaseServerConfig):
210
227
  return values
211
228
 
212
229
 
213
- class StreamableHTTPServerConfig(BaseServerConfig):
214
- """
215
- Configuration for an MCP server using Streamable HTTP
216
-
217
- Authentication can be provided in multiple ways:
218
- 1. Using auth_header + auth_token: Will add a specific header with the token
219
- Example: auth_header="Authorization", auth_token="Bearer abc123"
220
-
221
- 2. Using the custom_headers dict: For more complex authentication scenarios
222
- Example: custom_headers={"X-API-Key": "abc123", "X-Custom-Header": "value"}
223
- """
230
+ class StreamableHTTPServerConfig(HTTPBasedServerConfig):
231
+ """Configuration for an MCP server using Streamable HTTP"""
224
232
 
225
233
  type: MCPServerType = MCPServerType.STREAMABLE_HTTP
226
- server_url: str = Field(..., description="The URL path for the streamable HTTP server (e.g., 'example/mcp')")
227
- auth_header: Optional[str] = Field(None, description="The name of the authentication header (e.g., 'Authorization')")
228
- auth_token: Optional[str] = Field(None, description="The authentication token or API key value")
229
- custom_headers: Optional[dict[str, str]] = Field(None, description="Custom HTTP headers to include with streamable HTTP requests")
230
-
231
- def resolve_token(self) -> Optional[str]:
232
- """
233
- Extract token for storage if auth_header/auth_token are provided
234
- and not already in custom_headers.
235
-
236
- Returns:
237
- The resolved token (without Bearer prefix) if it should be stored separately, None otherwise
238
- """
239
- if self.auth_token and self.auth_header:
240
- # Check if custom_headers already has the auth header
241
- if not self.custom_headers or self.auth_header not in self.custom_headers:
242
- # Strip Bearer prefix if present
243
- if self.auth_token.startswith(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} "):
244
- return self.auth_token[len(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} ") :]
245
- return self.auth_token
246
- return None
247
-
248
- def resolve_environment_variables(self, environment_variables: Optional[Dict[str, str]] = None) -> None:
249
- if self.auth_token and super().is_templated_tool_variable(self.auth_token):
250
- self.auth_token = super().get_tool_variable(self.auth_token, environment_variables)
251
-
252
- self.custom_headers = super().resolve_custom_headers(self.custom_headers, environment_variables)
253
234
 
254
235
  def model_post_init(self, __context) -> None:
255
236
  """Validate the server URL format."""
@@ -275,14 +256,9 @@ class StreamableHTTPServerConfig(BaseServerConfig):
275
256
  "url": self.server_url,
276
257
  }
277
258
 
278
- # Handle custom headers
279
- if self.custom_headers is not None or (self.auth_header is not None and self.auth_token is not None):
280
- headers = self.custom_headers.copy() if self.custom_headers else {}
281
-
282
- # Add auth header if specified
283
- if self.auth_header is not None and self.auth_token is not None:
284
- headers[self.auth_header] = self.auth_token
285
-
259
+ # Handle custom headers using shared method
260
+ headers = self._build_headers_dict()
261
+ if headers:
286
262
  values["headers"] = headers
287
263
 
288
264
  return values
@@ -1,5 +1,4 @@
1
1
  import inspect
2
- import warnings
3
2
  from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
4
3
 
5
4
  from docstring_parser import parse
@@ -101,7 +100,7 @@ def type_to_json_schema_type(py_type) -> dict:
101
100
  args = get_args(py_type)
102
101
  if len(args) == 0:
103
102
  # is this correct
104
- warnings.warn("Defaulting to string type for untyped List")
103
+ logger.warning("Defaulting to string type for untyped List")
105
104
  return {
106
105
  "type": "array",
107
106
  "items": {"type": "string"},
@@ -662,6 +661,16 @@ def normalize_mcp_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
662
661
  # Handle anyOf (complex union types)
663
662
  if "anyOf" in prop_schema:
664
663
  for option in prop_schema["anyOf"]:
664
+ # Add explicit type to $ref options for flattening support
665
+ if "$ref" in option and "type" not in option:
666
+ if defs and option["$ref"].startswith("#/$defs/"):
667
+ def_name = option["$ref"].split("/")[-1]
668
+ if def_name in defs and "type" in defs[def_name]:
669
+ option["type"] = defs[def_name]["type"]
670
+ # Default to object if type can't be resolved
671
+ if "type" not in option:
672
+ option["type"] = "object"
673
+ # Recursively normalize object types
665
674
  if isinstance(option, dict) and option.get("type") == "object":
666
675
  normalize_object_schema(option, defs)
667
676
 
@@ -710,28 +719,131 @@ def generate_tool_schema_for_mcp(
710
719
  # Normalise so downstream code can treat it consistently.
711
720
  parameters_schema.setdefault("required", [])
712
721
 
713
- # Process properties to handle anyOf types and make optional fields strict-compatible
714
- # TODO: de-duplicate with handling in normalize_mcp_schema
722
+ # Get $defs for $ref resolution
723
+ defs = parameters_schema.get("$defs", {})
724
+
725
+ def deduplicate_anyof(anyof_list):
726
+ """
727
+ Deduplicate entries in an anyOf array based on their content.
728
+
729
+ Rules:
730
+ 1. Remove exact duplicates (same type, same properties)
731
+ 2. For duplicate types with different metadata (e.g., format):
732
+ - Keep the most specific version (with format/constraints)
733
+ - If one has format and others don't, keep only the one with format
734
+ """
735
+ if not anyof_list:
736
+ return anyof_list
737
+
738
+ seen = []
739
+ result = []
740
+
741
+ for item in anyof_list:
742
+ if not isinstance(item, dict):
743
+ if item not in seen:
744
+ seen.append(item)
745
+ result.append(item)
746
+ continue
747
+
748
+ # Create a hashable representation for comparison
749
+ # Sort keys to ensure consistent comparison
750
+ item_type = item.get("type")
751
+ item_format = item.get("format")
752
+
753
+ # Check if we've seen this exact item
754
+ is_duplicate = False
755
+ for existing_idx, existing in enumerate(result):
756
+ if not isinstance(existing, dict):
757
+ continue
758
+
759
+ existing_type = existing.get("type")
760
+ existing_format = existing.get("format")
761
+
762
+ # Exact match - skip this item
763
+ if item == existing:
764
+ is_duplicate = True
765
+ break
766
+
767
+ # Same type with different format handling
768
+ if item_type and item_type == existing_type:
769
+ # Both have same type
770
+ if item_format and not existing_format:
771
+ # New item has format, existing doesn't - replace existing with new
772
+ result[existing_idx] = item
773
+ is_duplicate = True
774
+ break
775
+ elif not item_format and existing_format:
776
+ # Existing has format, new doesn't - keep existing, skip new
777
+ is_duplicate = True
778
+ break
779
+ elif item_format == existing_format:
780
+ # Same type and format (or both None) - compare full objects
781
+ # Prefer the one with more properties/constraints
782
+ if len(item) >= len(existing):
783
+ result[existing_idx] = item
784
+ is_duplicate = True
785
+ break
786
+
787
+ if not is_duplicate:
788
+ result.append(item)
789
+
790
+ return result
791
+
792
+ def inline_ref(schema_node, defs, depth=0, max_depth=10):
793
+ """
794
+ Recursively inline all $ref references in a schema node.
795
+ Returns a new schema with all $refs replaced by their definitions.
796
+ """
797
+ if depth > max_depth:
798
+ return schema_node # Prevent infinite recursion
799
+
800
+ if not isinstance(schema_node, dict):
801
+ return schema_node
802
+
803
+ # Make a copy to avoid modifying the original
804
+ result = schema_node.copy()
805
+
806
+ # If this node has a $ref, resolve it and merge
807
+ if "$ref" in result:
808
+ ref_path = result["$ref"]
809
+ if ref_path.startswith("#/$defs/"):
810
+ def_name = ref_path.split("/")[-1]
811
+ if def_name in defs:
812
+ # Get the referenced schema
813
+ ref_schema = defs[def_name].copy()
814
+ # Remove the $ref
815
+ del result["$ref"]
816
+ # Merge the referenced schema into result
817
+ # The referenced schema properties take precedence
818
+ for key, value in ref_schema.items():
819
+ if key not in result:
820
+ result[key] = value
821
+ # Recursively inline any $refs in the merged schema
822
+ result = inline_ref(result, defs, depth + 1, max_depth)
823
+
824
+ # Recursively process nested structures
825
+ if "anyOf" in result:
826
+ # Inline refs in each anyOf option
827
+ result["anyOf"] = [inline_ref(opt, defs, depth + 1, max_depth) for opt in result["anyOf"]]
828
+ # Deduplicate anyOf entries
829
+ result["anyOf"] = deduplicate_anyof(result["anyOf"])
830
+ if "properties" in result and isinstance(result["properties"], dict):
831
+ result["properties"] = {
832
+ prop_name: inline_ref(prop_schema, defs, depth + 1, max_depth) for prop_name, prop_schema in result["properties"].items()
833
+ }
834
+ if "items" in result:
835
+ result["items"] = inline_ref(result["items"], defs, depth + 1, max_depth)
836
+
837
+ return result
838
+
839
+ # Process properties to inline all $refs while keeping anyOf structure
715
840
  if "properties" in parameters_schema:
716
- for field_name, field_props in parameters_schema["properties"].items():
717
- # Handle anyOf types by flattening to type array
718
- if "anyOf" in field_props and "type" not in field_props:
719
- types = []
720
- format_value = None
721
- for option in field_props["anyOf"]:
722
- if "type" in option:
723
- types.append(option["type"])
724
- # Capture format if present (e.g., uuid format for strings)
725
- if "format" in option and not format_value:
726
- format_value = option["format"]
727
- if types:
728
- # Deduplicate types using set
729
- field_props["type"] = list(dict.fromkeys(types))
730
- # Only add format if the field is not optional (doesn't have null type)
731
- if format_value and len(field_props["type"]) == 1 and "null" not in field_props["type"]:
732
- field_props["format"] = format_value
733
- # Remove the anyOf since we've flattened it
734
- del field_props["anyOf"]
841
+ for field_name in list(parameters_schema["properties"].keys()):
842
+ field_props = parameters_schema["properties"][field_name]
843
+
844
+ # Inline all $refs in this property (recursively)
845
+ field_props = inline_ref(field_props, defs)
846
+ parameters_schema["properties"][field_name] = field_props
735
847
 
736
848
  # For strict mode: heal optional fields by making them required with null type
737
849
  if strict and field_name not in parameters_schema["required"]: