letta-nightly 0.11.6.dev20250903104037__py3-none-any.whl → 0.11.7.dev20250904045700__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.
Files changed (138) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +10 -14
  3. letta/agents/base_agent.py +18 -0
  4. letta/agents/helpers.py +32 -7
  5. letta/agents/letta_agent.py +953 -762
  6. letta/agents/voice_agent.py +1 -1
  7. letta/client/streaming.py +0 -1
  8. letta/constants.py +11 -8
  9. letta/errors.py +9 -0
  10. letta/functions/function_sets/base.py +77 -69
  11. letta/functions/function_sets/builtin.py +41 -22
  12. letta/functions/function_sets/multi_agent.py +1 -2
  13. letta/functions/schema_generator.py +0 -1
  14. letta/helpers/converters.py +8 -3
  15. letta/helpers/datetime_helpers.py +5 -4
  16. letta/helpers/message_helper.py +1 -2
  17. letta/helpers/pinecone_utils.py +0 -1
  18. letta/helpers/tool_rule_solver.py +10 -0
  19. letta/helpers/tpuf_client.py +848 -0
  20. letta/interface.py +8 -8
  21. letta/interfaces/anthropic_streaming_interface.py +7 -0
  22. letta/interfaces/openai_streaming_interface.py +29 -6
  23. letta/llm_api/anthropic_client.py +188 -18
  24. letta/llm_api/azure_client.py +0 -1
  25. letta/llm_api/bedrock_client.py +1 -2
  26. letta/llm_api/deepseek_client.py +319 -5
  27. letta/llm_api/google_vertex_client.py +75 -17
  28. letta/llm_api/groq_client.py +0 -1
  29. letta/llm_api/helpers.py +2 -2
  30. letta/llm_api/llm_api_tools.py +1 -50
  31. letta/llm_api/llm_client.py +6 -8
  32. letta/llm_api/mistral.py +1 -1
  33. letta/llm_api/openai.py +16 -13
  34. letta/llm_api/openai_client.py +31 -16
  35. letta/llm_api/together_client.py +0 -1
  36. letta/llm_api/xai_client.py +0 -1
  37. letta/local_llm/chat_completion_proxy.py +7 -6
  38. letta/local_llm/settings/settings.py +1 -1
  39. letta/orm/__init__.py +1 -0
  40. letta/orm/agent.py +8 -6
  41. letta/orm/archive.py +9 -1
  42. letta/orm/block.py +3 -4
  43. letta/orm/block_history.py +3 -1
  44. letta/orm/group.py +2 -3
  45. letta/orm/identity.py +1 -2
  46. letta/orm/job.py +1 -2
  47. letta/orm/llm_batch_items.py +1 -2
  48. letta/orm/message.py +8 -4
  49. letta/orm/mixins.py +18 -0
  50. letta/orm/organization.py +2 -0
  51. letta/orm/passage.py +8 -1
  52. letta/orm/passage_tag.py +55 -0
  53. letta/orm/sandbox_config.py +1 -3
  54. letta/orm/step.py +1 -2
  55. letta/orm/tool.py +1 -0
  56. letta/otel/resource.py +2 -2
  57. letta/plugins/plugins.py +1 -1
  58. letta/prompts/prompt_generator.py +10 -2
  59. letta/schemas/agent.py +11 -0
  60. letta/schemas/archive.py +4 -0
  61. letta/schemas/block.py +13 -0
  62. letta/schemas/embedding_config.py +0 -1
  63. letta/schemas/enums.py +24 -7
  64. letta/schemas/group.py +12 -0
  65. letta/schemas/letta_message.py +55 -1
  66. letta/schemas/letta_message_content.py +28 -0
  67. letta/schemas/letta_request.py +21 -4
  68. letta/schemas/letta_stop_reason.py +9 -1
  69. letta/schemas/llm_config.py +24 -8
  70. letta/schemas/mcp.py +0 -3
  71. letta/schemas/memory.py +14 -0
  72. letta/schemas/message.py +245 -141
  73. letta/schemas/openai/chat_completion_request.py +2 -1
  74. letta/schemas/passage.py +1 -0
  75. letta/schemas/providers/bedrock.py +1 -1
  76. letta/schemas/providers/openai.py +2 -2
  77. letta/schemas/tool.py +11 -5
  78. letta/schemas/tool_execution_result.py +0 -1
  79. letta/schemas/tool_rule.py +71 -0
  80. letta/serialize_schemas/marshmallow_agent.py +1 -2
  81. letta/server/rest_api/app.py +3 -3
  82. letta/server/rest_api/auth/index.py +0 -1
  83. letta/server/rest_api/interface.py +3 -11
  84. letta/server/rest_api/redis_stream_manager.py +3 -4
  85. letta/server/rest_api/routers/v1/agents.py +143 -84
  86. letta/server/rest_api/routers/v1/blocks.py +1 -1
  87. letta/server/rest_api/routers/v1/folders.py +1 -1
  88. letta/server/rest_api/routers/v1/groups.py +23 -22
  89. letta/server/rest_api/routers/v1/internal_templates.py +68 -0
  90. letta/server/rest_api/routers/v1/sandbox_configs.py +11 -5
  91. letta/server/rest_api/routers/v1/sources.py +1 -1
  92. letta/server/rest_api/routers/v1/tools.py +167 -15
  93. letta/server/rest_api/streaming_response.py +4 -3
  94. letta/server/rest_api/utils.py +75 -18
  95. letta/server/server.py +24 -35
  96. letta/services/agent_manager.py +359 -45
  97. letta/services/agent_serialization_manager.py +23 -3
  98. letta/services/archive_manager.py +72 -3
  99. letta/services/block_manager.py +1 -2
  100. letta/services/context_window_calculator/token_counter.py +11 -6
  101. letta/services/file_manager.py +1 -3
  102. letta/services/files_agents_manager.py +2 -4
  103. letta/services/group_manager.py +73 -12
  104. letta/services/helpers/agent_manager_helper.py +5 -5
  105. letta/services/identity_manager.py +8 -3
  106. letta/services/job_manager.py +2 -14
  107. letta/services/llm_batch_manager.py +1 -3
  108. letta/services/mcp/base_client.py +1 -2
  109. letta/services/mcp_manager.py +5 -6
  110. letta/services/message_manager.py +536 -15
  111. letta/services/organization_manager.py +1 -2
  112. letta/services/passage_manager.py +287 -12
  113. letta/services/provider_manager.py +1 -3
  114. letta/services/sandbox_config_manager.py +12 -7
  115. letta/services/source_manager.py +1 -2
  116. letta/services/step_manager.py +0 -1
  117. letta/services/summarizer/summarizer.py +4 -2
  118. letta/services/telemetry_manager.py +1 -3
  119. letta/services/tool_executor/builtin_tool_executor.py +136 -316
  120. letta/services/tool_executor/core_tool_executor.py +231 -74
  121. letta/services/tool_executor/files_tool_executor.py +2 -2
  122. letta/services/tool_executor/mcp_tool_executor.py +0 -1
  123. letta/services/tool_executor/multi_agent_tool_executor.py +2 -2
  124. letta/services/tool_executor/sandbox_tool_executor.py +0 -1
  125. letta/services/tool_executor/tool_execution_sandbox.py +2 -3
  126. letta/services/tool_manager.py +181 -64
  127. letta/services/tool_sandbox/modal_deployment_manager.py +2 -2
  128. letta/services/user_manager.py +1 -2
  129. letta/settings.py +5 -3
  130. letta/streaming_interface.py +3 -3
  131. letta/system.py +1 -1
  132. letta/utils.py +0 -1
  133. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/METADATA +11 -7
  134. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/RECORD +137 -135
  135. letta/llm_api/deepseek.py +0 -303
  136. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/WHEEL +0 -0
  137. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/entry_points.txt +0 -0
  138. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/licenses/LICENSE +0 -0
letta/schemas/message.py CHANGED
@@ -7,11 +7,11 @@ import uuid
7
7
  import warnings
8
8
  from collections import OrderedDict
9
9
  from datetime import datetime, timezone
10
- from typing import Any, Dict, List, Literal, Optional, Union
10
+ from enum import Enum
11
+ from typing import Annotated, Any, Dict, List, Literal, Optional, Union
11
12
 
12
- from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall
13
- from openai.types.chat.chat_completion_message_tool_call import Function as OpenAIFunction
14
- from pydantic import BaseModel, Field, field_validator
13
+ from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall, Function as OpenAIFunction
14
+ from pydantic import BaseModel, Field, field_validator, model_validator
15
15
 
16
16
  from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG, TOOL_CALL_ID_MAX_LEN
17
17
  from letta.helpers.datetime_helpers import get_utc_time, is_utc_datetime
@@ -20,6 +20,8 @@ from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG
20
20
  from letta.schemas.enums import MessageRole
21
21
  from letta.schemas.letta_base import OrmMetadataBase
22
22
  from letta.schemas.letta_message import (
23
+ ApprovalRequestMessage,
24
+ ApprovalResponseMessage,
23
25
  AssistantMessage,
24
26
  HiddenReasoningMessage,
25
27
  LettaMessage,
@@ -66,13 +68,21 @@ def add_inner_thoughts_to_tool_call(
66
68
  raise e
67
69
 
68
70
 
69
- class BaseMessage(OrmMetadataBase):
70
- __id_prefix__ = "message"
71
+ class MessageCreateType(str, Enum):
72
+ message = "message"
73
+ approval = "approval"
74
+
75
+
76
+ class MessageCreateBase(BaseModel):
77
+ type: MessageCreateType = Field(..., description="The message type to be created.")
71
78
 
72
79
 
73
- class MessageCreate(BaseModel):
80
+ class MessageCreate(MessageCreateBase):
74
81
  """Request to create a message"""
75
82
 
83
+ type: Optional[Literal[MessageCreateType.message]] = Field(
84
+ default=MessageCreateType.message, description="The message type to be created."
85
+ )
76
86
  # In the simplified format, only allow simple roles
77
87
  role: Literal[
78
88
  MessageRole.user,
@@ -98,6 +108,18 @@ class MessageCreate(BaseModel):
98
108
  return data
99
109
 
100
110
 
111
+ class ApprovalCreate(MessageCreateBase):
112
+ """Input to approve or deny a tool call request"""
113
+
114
+ type: Literal[MessageCreateType.approval] = Field(default=MessageCreateType.approval, description="The message type to be created.")
115
+ approve: bool = Field(..., description="Whether the tool has been approved")
116
+ approval_request_id: str = Field(..., description="The message ID of the approval request")
117
+ reason: Optional[str] = Field(None, description="An optional explanation for the provided approval status")
118
+
119
+
120
+ MessageCreateUnion = Union[MessageCreate, ApprovalCreate]
121
+
122
+
101
123
  class MessageUpdate(BaseModel):
102
124
  """Request to update a message"""
103
125
 
@@ -126,6 +148,10 @@ class MessageUpdate(BaseModel):
126
148
  return data
127
149
 
128
150
 
151
+ class BaseMessage(OrmMetadataBase):
152
+ __id_prefix__ = "message"
153
+
154
+
129
155
  class Message(BaseMessage):
130
156
  """
131
157
  Letta's internal representation of a message. Includes methods to convert to/from LLM provider formats.
@@ -174,13 +200,18 @@ class Message(BaseMessage):
174
200
  is_err: Optional[bool] = Field(
175
201
  default=None, description="Whether this message is part of an error step. Used only for debugging purposes."
176
202
  )
203
+ approval_request_id: Optional[str] = Field(
204
+ default=None, description="The id of the approval request if this message is associated with a tool call request."
205
+ )
206
+ approve: Optional[bool] = Field(default=None, description="Whether tool call is approved.")
207
+ denial_reason: Optional[str] = Field(default=None, description="The reason the tool call request was denied.")
177
208
  # This overrides the optional base orm schema, created_at MUST exist on all messages objects
178
209
  created_at: datetime = Field(default_factory=get_utc_time, description="The timestamp when the object was created.")
179
210
 
180
211
  @field_validator("role")
181
212
  @classmethod
182
213
  def validate_role(cls, v: str) -> str:
183
- roles = ["system", "assistant", "user", "tool"]
214
+ roles = ["system", "assistant", "user", "tool", "approval"]
184
215
  assert v in roles, f"Role must be one of {roles}"
185
216
  return v
186
217
 
@@ -251,20 +282,77 @@ class Message(BaseMessage):
251
282
  include_err: Optional[bool] = None,
252
283
  ) -> List[LettaMessage]:
253
284
  """Convert message object (in DB format) to the style used by the original Letta API"""
254
-
255
- # TODO (cliandy): break this into more manageable pieces
285
+ messages = []
256
286
  if self.role == MessageRole.assistant:
257
- messages = []
258
- # Handle reasoning
259
287
  if self.content:
260
- # Check for ReACT-style COT inside of TextContent
261
- if len(self.content) == 1 and isinstance(self.content[0], TextContent):
262
- otid = Message.generate_otid_from_id(self.id, len(messages))
288
+ messages.extend(self._convert_reasoning_messages())
289
+ if self.tool_calls is not None:
290
+ messages.extend(
291
+ self._convert_tool_call_messages(
292
+ current_message_count=len(messages),
293
+ use_assistant_message=use_assistant_message,
294
+ assistant_message_tool_name=assistant_message_tool_name,
295
+ assistant_message_tool_kwarg=assistant_message_tool_kwarg,
296
+ ),
297
+ )
298
+ elif self.role == MessageRole.tool:
299
+ messages.append(self._convert_tool_return_message())
300
+ elif self.role == MessageRole.user:
301
+ messages.append(self._convert_user_message())
302
+ elif self.role == MessageRole.system:
303
+ messages.append(self._convert_system_message())
304
+ elif self.role == MessageRole.approval:
305
+ if self.content:
306
+ messages.extend(self._convert_reasoning_messages())
307
+ if self.tool_calls is not None:
308
+ tool_calls = self._convert_tool_call_messages()
309
+ assert len(tool_calls) == 1
310
+ approval_request_message = ApprovalRequestMessage(**tool_calls[0].model_dump(exclude={"message_type"}))
311
+ messages.append(approval_request_message)
312
+ else:
313
+ approval_response_message = ApprovalResponseMessage(
314
+ id=self.id,
315
+ date=self.created_at,
316
+ otid=self.otid,
317
+ approve=self.approve,
318
+ approval_request_id=self.approval_request_id,
319
+ reason=self.denial_reason,
320
+ )
321
+ messages.append(approval_response_message)
322
+ else:
323
+ raise ValueError(f"Unknown role: {self.role}")
324
+
325
+ return messages[::-1] if reverse else messages
326
+
327
+ def _convert_reasoning_messages(self, current_message_count: int = 0) -> List[LettaMessage]:
328
+ messages = []
329
+ # Check for ReACT-style COT inside of TextContent
330
+ if len(self.content) == 1 and isinstance(self.content[0], TextContent):
331
+ otid = Message.generate_otid_from_id(self.id, current_message_count + len(messages))
332
+ messages.append(
333
+ ReasoningMessage(
334
+ id=self.id,
335
+ date=self.created_at,
336
+ reasoning=self.content[0].text,
337
+ name=self.name,
338
+ otid=otid,
339
+ sender_id=self.sender_id,
340
+ step_id=self.step_id,
341
+ is_err=self.is_err,
342
+ )
343
+ )
344
+ # Otherwise, we may have a list of multiple types
345
+ else:
346
+ # TODO we can probably collapse these two cases into a single loop
347
+ for content_part in self.content:
348
+ otid = Message.generate_otid_from_id(self.id, current_message_count + len(messages))
349
+ if isinstance(content_part, TextContent):
350
+ # COT
263
351
  messages.append(
264
352
  ReasoningMessage(
265
353
  id=self.id,
266
354
  date=self.created_at,
267
- reasoning=self.content[0].text,
355
+ reasoning=content_part.text,
268
356
  name=self.name,
269
357
  otid=otid,
270
358
  sender_id=self.sender_id,
@@ -272,127 +360,106 @@ class Message(BaseMessage):
272
360
  is_err=self.is_err,
273
361
  )
274
362
  )
275
- # Otherwise, we may have a list of multiple types
276
- else:
277
- # TODO we can probably collapse these two cases into a single loop
278
- for content_part in self.content:
279
- otid = Message.generate_otid_from_id(self.id, len(messages))
280
- if isinstance(content_part, TextContent):
281
- # COT
282
- messages.append(
283
- ReasoningMessage(
284
- id=self.id,
285
- date=self.created_at,
286
- reasoning=content_part.text,
287
- name=self.name,
288
- otid=otid,
289
- sender_id=self.sender_id,
290
- step_id=self.step_id,
291
- is_err=self.is_err,
292
- )
293
- )
294
- elif isinstance(content_part, ReasoningContent):
295
- # "native" COT
296
- messages.append(
297
- ReasoningMessage(
298
- id=self.id,
299
- date=self.created_at,
300
- reasoning=content_part.reasoning,
301
- source="reasoner_model", # TODO do we want to tag like this?
302
- signature=content_part.signature,
303
- name=self.name,
304
- otid=otid,
305
- step_id=self.step_id,
306
- is_err=self.is_err,
307
- )
308
- )
309
- elif isinstance(content_part, RedactedReasoningContent):
310
- # "native" redacted/hidden COT
311
- messages.append(
312
- HiddenReasoningMessage(
313
- id=self.id,
314
- date=self.created_at,
315
- state="redacted",
316
- hidden_reasoning=content_part.data,
317
- name=self.name,
318
- otid=otid,
319
- sender_id=self.sender_id,
320
- step_id=self.step_id,
321
- is_err=self.is_err,
322
- )
323
- )
324
- elif isinstance(content_part, OmittedReasoningContent):
325
- # Special case for "hidden reasoning" models like o1/o3
326
- # NOTE: we also have to think about how to return this during streaming
327
- messages.append(
328
- HiddenReasoningMessage(
329
- id=self.id,
330
- date=self.created_at,
331
- state="omitted",
332
- name=self.name,
333
- otid=otid,
334
- step_id=self.step_id,
335
- is_err=self.is_err,
336
- )
337
- )
338
- else:
339
- warnings.warn(f"Unrecognized content part in assistant message: {content_part}")
340
-
341
- if self.tool_calls is not None:
342
- # This is type FunctionCall
343
- for tool_call in self.tool_calls:
344
- otid = Message.generate_otid_from_id(self.id, len(messages))
345
- # If we're supporting using assistant message,
346
- # then we want to treat certain function calls as a special case
347
- if use_assistant_message and tool_call.function.name == assistant_message_tool_name:
348
- # We need to unpack the actual message contents from the function call
349
- try:
350
- func_args = parse_json(tool_call.function.arguments)
351
- message_string = validate_function_response(func_args[assistant_message_tool_kwarg], 0, truncate=False)
352
- except KeyError:
353
- raise ValueError(f"Function call {tool_call.function.name} missing {assistant_message_tool_kwarg} argument")
354
- messages.append(
355
- AssistantMessage(
356
- id=self.id,
357
- date=self.created_at,
358
- content=message_string,
359
- name=self.name,
360
- otid=otid,
361
- sender_id=self.sender_id,
362
- step_id=self.step_id,
363
- is_err=self.is_err,
364
- )
363
+ elif isinstance(content_part, ReasoningContent):
364
+ # "native" COT
365
+ messages.append(
366
+ ReasoningMessage(
367
+ id=self.id,
368
+ date=self.created_at,
369
+ reasoning=content_part.reasoning,
370
+ source="reasoner_model", # TODO do we want to tag like this?
371
+ signature=content_part.signature,
372
+ name=self.name,
373
+ otid=otid,
374
+ step_id=self.step_id,
375
+ is_err=self.is_err,
365
376
  )
366
- else:
367
- messages.append(
368
- ToolCallMessage(
369
- id=self.id,
370
- date=self.created_at,
371
- tool_call=ToolCall(
372
- name=tool_call.function.name,
373
- arguments=tool_call.function.arguments,
374
- tool_call_id=tool_call.id,
375
- ),
376
- name=self.name,
377
- otid=otid,
378
- sender_id=self.sender_id,
379
- step_id=self.step_id,
380
- is_err=self.is_err,
381
- )
377
+ )
378
+ elif isinstance(content_part, RedactedReasoningContent):
379
+ # "native" redacted/hidden COT
380
+ messages.append(
381
+ HiddenReasoningMessage(
382
+ id=self.id,
383
+ date=self.created_at,
384
+ state="redacted",
385
+ hidden_reasoning=content_part.data,
386
+ name=self.name,
387
+ otid=otid,
388
+ sender_id=self.sender_id,
389
+ step_id=self.step_id,
390
+ is_err=self.is_err,
382
391
  )
392
+ )
393
+ elif isinstance(content_part, OmittedReasoningContent):
394
+ # Special case for "hidden reasoning" models like o1/o3
395
+ # NOTE: we also have to think about how to return this during streaming
396
+ messages.append(
397
+ HiddenReasoningMessage(
398
+ id=self.id,
399
+ date=self.created_at,
400
+ state="omitted",
401
+ name=self.name,
402
+ otid=otid,
403
+ step_id=self.step_id,
404
+ is_err=self.is_err,
405
+ )
406
+ )
407
+ else:
408
+ warnings.warn(f"Unrecognized content part in assistant message: {content_part}")
409
+ return messages
383
410
 
384
- elif self.role == MessageRole.tool:
385
- messages = [self._convert_tool_message()]
386
- elif self.role == MessageRole.user:
387
- messages = [self._convert_user_message()]
388
- elif self.role == MessageRole.system:
389
- messages = [self._convert_system_message()]
390
- else:
391
- raise ValueError(f"Unknown role: {self.role}")
392
-
393
- return messages[::-1] if reverse else messages
411
+ def _convert_tool_call_messages(
412
+ self,
413
+ current_message_count: int = 0,
414
+ use_assistant_message: bool = False,
415
+ assistant_message_tool_name: str = DEFAULT_MESSAGE_TOOL,
416
+ assistant_message_tool_kwarg: str = DEFAULT_MESSAGE_TOOL_KWARG,
417
+ ) -> List[LettaMessage]:
418
+ messages = []
419
+ # This is type FunctionCall
420
+ for tool_call in self.tool_calls:
421
+ otid = Message.generate_otid_from_id(self.id, current_message_count + len(messages))
422
+ # If we're supporting using assistant message,
423
+ # then we want to treat certain function calls as a special case
424
+ if use_assistant_message and tool_call.function.name == assistant_message_tool_name:
425
+ # We need to unpack the actual message contents from the function call
426
+ try:
427
+ func_args = parse_json(tool_call.function.arguments)
428
+ message_string = validate_function_response(func_args[assistant_message_tool_kwarg], 0, truncate=False)
429
+ except KeyError:
430
+ raise ValueError(f"Function call {tool_call.function.name} missing {assistant_message_tool_kwarg} argument")
431
+ messages.append(
432
+ AssistantMessage(
433
+ id=self.id,
434
+ date=self.created_at,
435
+ content=message_string,
436
+ name=self.name,
437
+ otid=otid,
438
+ sender_id=self.sender_id,
439
+ step_id=self.step_id,
440
+ is_err=self.is_err,
441
+ )
442
+ )
443
+ else:
444
+ messages.append(
445
+ ToolCallMessage(
446
+ id=self.id,
447
+ date=self.created_at,
448
+ tool_call=ToolCall(
449
+ name=tool_call.function.name,
450
+ arguments=tool_call.function.arguments,
451
+ tool_call_id=tool_call.id,
452
+ ),
453
+ name=self.name,
454
+ otid=otid,
455
+ sender_id=self.sender_id,
456
+ step_id=self.step_id,
457
+ is_err=self.is_err,
458
+ )
459
+ )
460
+ return messages
394
461
 
395
- def _convert_tool_message(self) -> ToolReturnMessage:
462
+ def _convert_tool_return_message(self) -> ToolReturnMessage:
396
463
  """Convert tool role message to ToolReturnMessage
397
464
 
398
465
  the tool return is packaged as follows:
@@ -679,8 +746,10 @@ class Message(BaseMessage):
679
746
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
680
747
  put_inner_thoughts_in_kwargs: bool = False,
681
748
  use_developer_message: bool = False,
682
- ) -> dict:
749
+ ) -> dict | None:
683
750
  """Go from Message class to ChatCompletion message object"""
751
+ if self.role == "approval" and self.tool_calls is None:
752
+ return None
684
753
 
685
754
  # TODO change to pydantic casting, eg `return SystemMessageModel(self)`
686
755
  # If we only have one content part and it's text, treat it as COT
@@ -718,11 +787,11 @@ class Message(BaseMessage):
718
787
  "role": self.role,
719
788
  }
720
789
 
721
- elif self.role == "assistant":
790
+ elif self.role == "assistant" or self.role == "approval":
722
791
  assert self.tool_calls is not None or text_content is not None
723
792
  openai_message = {
724
793
  "content": None if (put_inner_thoughts_in_kwargs and self.tool_calls is not None) else text_content,
725
- "role": self.role,
794
+ "role": "assistant",
726
795
  }
727
796
 
728
797
  if self.tool_calls is not None:
@@ -771,17 +840,37 @@ class Message(BaseMessage):
771
840
 
772
841
  return openai_message
773
842
 
843
+ @staticmethod
844
+ def to_openai_dicts_from_list(
845
+ messages: List[Message],
846
+ max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
847
+ put_inner_thoughts_in_kwargs: bool = False,
848
+ use_developer_message: bool = False,
849
+ ) -> List[dict]:
850
+ result = [
851
+ m.to_openai_dict(
852
+ max_tool_id_length=max_tool_id_length,
853
+ put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
854
+ use_developer_message=use_developer_message,
855
+ )
856
+ for m in messages
857
+ ]
858
+ result = [m for m in result if m is not None]
859
+ return result
860
+
774
861
  def to_anthropic_dict(
775
862
  self,
776
863
  inner_thoughts_xml_tag="thinking",
777
864
  put_inner_thoughts_in_kwargs: bool = False,
778
- ) -> dict:
865
+ ) -> dict | None:
779
866
  """
780
867
  Convert to an Anthropic message dictionary
781
868
 
782
869
  Args:
783
870
  inner_thoughts_xml_tag (str): The XML tag to wrap around inner thoughts
784
871
  """
872
+ if self.role == "approval" and self.tool_calls is None:
873
+ return None
785
874
 
786
875
  # Check for COT
787
876
  if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
@@ -839,10 +928,10 @@ class Message(BaseMessage):
839
928
  "role": self.role,
840
929
  }
841
930
 
842
- elif self.role == "assistant":
931
+ elif self.role == "assistant" or self.role == "approval":
843
932
  assert self.tool_calls is not None or text_content is not None
844
933
  anthropic_message = {
845
- "role": self.role,
934
+ "role": "assistant",
846
935
  }
847
936
  content = []
848
937
  # COT / reasoning / thinking
@@ -880,7 +969,6 @@ class Message(BaseMessage):
880
969
  # Tool calling
881
970
  if self.tool_calls is not None:
882
971
  for tool_call in self.tool_calls:
883
-
884
972
  if put_inner_thoughts_in_kwargs:
885
973
  tool_call_input = add_inner_thoughts_to_tool_call(
886
974
  tool_call,
@@ -923,6 +1011,22 @@ class Message(BaseMessage):
923
1011
 
924
1012
  return anthropic_message
925
1013
 
1014
+ @staticmethod
1015
+ def to_anthropic_dicts_from_list(
1016
+ messages: List[Message],
1017
+ inner_thoughts_xml_tag: str = "thinking",
1018
+ put_inner_thoughts_in_kwargs: bool = False,
1019
+ ) -> List[dict]:
1020
+ result = [
1021
+ m.to_anthropic_dict(
1022
+ inner_thoughts_xml_tag=inner_thoughts_xml_tag,
1023
+ put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1024
+ )
1025
+ for m in messages
1026
+ ]
1027
+ result = [m for m in result if m is not None]
1028
+ return result
1029
+
926
1030
  def to_google_ai_dict(self, put_inner_thoughts_in_kwargs: bool = True) -> dict:
927
1031
  """
928
1032
  Go from Message class to Google AI REST message object
@@ -1021,7 +1125,7 @@ class Message(BaseMessage):
1021
1125
  assert self.tool_call_id is not None, vars(self)
1022
1126
 
1023
1127
  if self.name is None:
1024
- warnings.warn(f"Couldn't find function name on tool call, defaulting to tool ID instead.")
1128
+ warnings.warn("Couldn't find function name on tool call, defaulting to tool ID instead.")
1025
1129
  function_name = self.tool_call_id
1026
1130
  else:
1027
1131
  function_name = self.name
@@ -50,7 +50,7 @@ def cast_message_to_subtype(m_dict: dict) -> ChatMessage:
50
50
  return SystemMessage(**m_dict)
51
51
  elif role == "user":
52
52
  return UserMessage(**m_dict)
53
- elif role == "assistant":
53
+ elif role == "assistant" or role == "approval":
54
54
  return AssistantMessage(**m_dict)
55
55
  elif role == "tool":
56
56
  return ToolMessage(**m_dict)
@@ -136,6 +136,7 @@ class ChatCompletionRequest(BaseModel):
136
136
  parallel_tool_calls: Optional[bool] = None
137
137
  instructions: Optional[str] = None
138
138
  verbosity: Optional[Literal["low", "medium", "high"]] = None # For verbosity control in GPT-5 models
139
+ reasoning_effort: Optional[Literal["minimal", "low", "medium", "high"]] = None # For reasoning effort control in reasoning models
139
140
 
140
141
  # function-calling related
141
142
  tools: Optional[List[Tool]] = None
letta/schemas/passage.py CHANGED
@@ -25,6 +25,7 @@ class PassageBase(OrmMetadataBase):
25
25
  file_id: Optional[str] = Field(None, description="The unique identifier of the file associated with the passage.")
26
26
  file_name: Optional[str] = Field(None, description="The name of the file (only for source passages).")
27
27
  metadata: Optional[Dict] = Field({}, validation_alias="metadata_", description="The metadata of the passage.")
28
+ tags: Optional[List[str]] = Field(None, description="Tags associated with this passage.")
28
29
 
29
30
 
30
31
  class Passage(PassageBase):
@@ -35,7 +35,7 @@ class BedrockProvider(Provider):
35
35
  response = await bedrock.list_inference_profiles()
36
36
  return response["inferenceProfileSummaries"]
37
37
  except Exception as e:
38
- logger.error(f"Error getting model list for bedrock: %s", e)
38
+ logger.error("Error getting model list for bedrock: %s", e)
39
39
  raise e
40
40
 
41
41
  async def check_api_key(self):
@@ -203,7 +203,7 @@ class OpenAIProvider(Provider):
203
203
  continue
204
204
  else:
205
205
  logger.debug(
206
- f"Skipping embedding models for %s by default, as we don't assume embeddings are supported."
206
+ "Skipping embedding models for %s by default, as we don't assume embeddings are supported."
207
207
  "Please open an issue on GitHub if support is required.",
208
208
  self.base_url,
209
209
  )
@@ -227,7 +227,7 @@ class OpenAIProvider(Provider):
227
227
  return LLM_MAX_TOKENS[model_name]
228
228
  else:
229
229
  logger.debug(
230
- f"Model %s on %s for provider %s not found in LLM_MAX_TOKENS. Using default of {{LLM_MAX_TOKENS['DEFAULT']}}",
230
+ "Model %s on %s for provider %s not found in LLM_MAX_TOKENS. Using default of {LLM_MAX_TOKENS['DEFAULT']}",
231
231
  model_name,
232
232
  self.base_url,
233
233
  self.__class__.__name__,
letta/schemas/tool.py CHANGED
@@ -67,6 +67,9 @@ class Tool(BaseTool):
67
67
  return_char_limit: int = Field(FUNCTION_RETURN_CHAR_LIMIT, description="The maximum number of characters in the response.")
68
68
  pip_requirements: list[PipRequirement] | None = Field(None, description="Optional list of pip packages required by this tool.")
69
69
  npm_requirements: list[NpmRequirement] | None = Field(None, description="Optional list of npm packages required by this tool.")
70
+ default_requires_approval: Optional[bool] = Field(
71
+ None, description="Default value for whether or not executing this tool requires approval."
72
+ )
70
73
 
71
74
  # metadata fields
72
75
  created_by_id: Optional[str] = Field(None, description="The id of the user that made this Tool.")
@@ -80,7 +83,8 @@ class Tool(BaseTool):
80
83
  """
81
84
  from letta.functions.helpers import generate_model_from_args_json_schema
82
85
 
83
- if self.tool_type == ToolType.CUSTOM:
86
+ if self.tool_type == ToolType.CUSTOM and not self.json_schema:
87
+ # attempt various fallbacks to get the JSON schema
84
88
  if not self.source_code:
85
89
  logger.error("Custom tool with id=%s is missing source_code field", self.id)
86
90
  raise ValueError(f"Custom tool with id={self.id} is missing source_code field.")
@@ -157,7 +161,7 @@ class Tool(BaseTool):
157
161
 
158
162
  class ToolCreate(LettaBase):
159
163
  description: Optional[str] = Field(None, description="The description of the tool.")
160
- tags: List[str] = Field([], description="Metadata tags.")
164
+ tags: Optional[List[str]] = Field(None, description="Metadata tags.")
161
165
  source_code: str = Field(..., description="The source code of the function.")
162
166
  source_type: str = Field("python", description="The source type of the function.")
163
167
  json_schema: Optional[Dict] = Field(
@@ -167,6 +171,7 @@ class ToolCreate(LettaBase):
167
171
  return_char_limit: int = Field(FUNCTION_RETURN_CHAR_LIMIT, description="The maximum number of characters in the response.")
168
172
  pip_requirements: list[PipRequirement] | None = Field(None, description="Optional list of pip packages required by this tool.")
169
173
  npm_requirements: list[NpmRequirement] | None = Field(None, description="Optional list of npm packages required by this tool.")
174
+ default_requires_approval: Optional[bool] = Field(None, description="Whether or not to require approval before executing this tool.")
170
175
 
171
176
  @classmethod
172
177
  def from_mcp(cls, mcp_server_name: str, mcp_tool: MCPTool) -> "ToolCreate":
@@ -213,9 +218,9 @@ class ToolCreate(LettaBase):
213
218
  composio_action_schemas = composio_toolset.get_action_schemas(actions=[action_name], check_connected_accounts=False)
214
219
 
215
220
  assert len(composio_action_schemas) > 0, "User supplied parameters do not match any Composio tools"
216
- assert (
217
- len(composio_action_schemas) == 1
218
- ), f"User supplied parameters match too many Composio tools; {len(composio_action_schemas)} > 1"
221
+ assert len(composio_action_schemas) == 1, (
222
+ f"User supplied parameters match too many Composio tools; {len(composio_action_schemas)} > 1"
223
+ )
219
224
 
220
225
  composio_action_schema = composio_action_schemas[0]
221
226
 
@@ -247,6 +252,7 @@ class ToolUpdate(LettaBase):
247
252
  pip_requirements: list[PipRequirement] | None = Field(None, description="Optional list of pip packages required by this tool.")
248
253
  npm_requirements: list[NpmRequirement] | None = Field(None, description="Optional list of npm packages required by this tool.")
249
254
  metadata_: Optional[Dict[str, Any]] = Field(None, description="A dictionary of additional metadata for the tool.")
255
+ default_requires_approval: Optional[bool] = Field(None, description="Whether or not to require approval before executing this tool.")
250
256
 
251
257
  model_config = ConfigDict(extra="ignore") # Allows extra fields without validation errors
252
258
  # TODO: Remove this, and clean usage of ToolUpdate everywhere else
@@ -6,7 +6,6 @@ from letta.schemas.agent import AgentState
6
6
 
7
7
 
8
8
  class ToolExecutionResult(BaseModel):
9
-
10
9
  status: Literal["success", "error"] = Field(..., description="The status of the tool execution and return object")
11
10
  func_return: Optional[Any] = Field(None, description="The function return object")
12
11
  agent_state: Optional[AgentState] = Field(None, description="The agent state")