letta-nightly 0.13.0.dev20251030104218__py3-none-any.whl → 0.13.1.dev20251031234110__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 (101) hide show
  1. letta/__init__.py +1 -1
  2. letta/adapters/simple_llm_stream_adapter.py +1 -0
  3. letta/agents/letta_agent_v2.py +8 -0
  4. letta/agents/letta_agent_v3.py +120 -27
  5. letta/agents/temporal/activities/__init__.py +25 -0
  6. letta/agents/temporal/activities/create_messages.py +26 -0
  7. letta/agents/temporal/activities/create_step.py +57 -0
  8. letta/agents/temporal/activities/example_activity.py +9 -0
  9. letta/agents/temporal/activities/execute_tool.py +130 -0
  10. letta/agents/temporal/activities/llm_request.py +114 -0
  11. letta/agents/temporal/activities/prepare_messages.py +27 -0
  12. letta/agents/temporal/activities/refresh_context.py +160 -0
  13. letta/agents/temporal/activities/summarize_conversation_history.py +77 -0
  14. letta/agents/temporal/activities/update_message_ids.py +25 -0
  15. letta/agents/temporal/activities/update_run.py +43 -0
  16. letta/agents/temporal/constants.py +59 -0
  17. letta/agents/temporal/temporal_agent_workflow.py +704 -0
  18. letta/agents/temporal/types.py +275 -0
  19. letta/constants.py +8 -0
  20. letta/errors.py +4 -0
  21. letta/functions/function_sets/base.py +0 -11
  22. letta/groups/helpers.py +7 -1
  23. letta/groups/sleeptime_multi_agent_v4.py +4 -3
  24. letta/interfaces/anthropic_streaming_interface.py +0 -1
  25. letta/interfaces/openai_streaming_interface.py +103 -100
  26. letta/llm_api/anthropic_client.py +57 -12
  27. letta/llm_api/bedrock_client.py +1 -0
  28. letta/llm_api/deepseek_client.py +3 -2
  29. letta/llm_api/google_vertex_client.py +1 -0
  30. letta/llm_api/groq_client.py +1 -0
  31. letta/llm_api/llm_client_base.py +15 -1
  32. letta/llm_api/openai.py +2 -2
  33. letta/llm_api/openai_client.py +17 -3
  34. letta/llm_api/xai_client.py +1 -0
  35. letta/orm/organization.py +4 -0
  36. letta/orm/sqlalchemy_base.py +7 -0
  37. letta/otel/tracing.py +131 -4
  38. letta/schemas/agent_file.py +10 -10
  39. letta/schemas/block.py +22 -3
  40. letta/schemas/enums.py +21 -0
  41. letta/schemas/environment_variables.py +3 -2
  42. letta/schemas/group.py +3 -3
  43. letta/schemas/letta_response.py +36 -4
  44. letta/schemas/llm_batch_job.py +3 -3
  45. letta/schemas/llm_config.py +27 -3
  46. letta/schemas/mcp.py +3 -2
  47. letta/schemas/mcp_server.py +3 -2
  48. letta/schemas/message.py +167 -49
  49. letta/schemas/organization.py +2 -1
  50. letta/schemas/passage.py +2 -1
  51. letta/schemas/provider_trace.py +2 -1
  52. letta/schemas/providers/openrouter.py +1 -2
  53. letta/schemas/run_metrics.py +2 -1
  54. letta/schemas/sandbox_config.py +3 -1
  55. letta/schemas/step_metrics.py +2 -1
  56. letta/schemas/tool_rule.py +2 -2
  57. letta/schemas/user.py +2 -1
  58. letta/server/rest_api/app.py +5 -1
  59. letta/server/rest_api/routers/v1/__init__.py +4 -0
  60. letta/server/rest_api/routers/v1/agents.py +71 -9
  61. letta/server/rest_api/routers/v1/blocks.py +7 -7
  62. letta/server/rest_api/routers/v1/groups.py +40 -0
  63. letta/server/rest_api/routers/v1/identities.py +2 -2
  64. letta/server/rest_api/routers/v1/internal_agents.py +31 -0
  65. letta/server/rest_api/routers/v1/internal_blocks.py +177 -0
  66. letta/server/rest_api/routers/v1/internal_runs.py +25 -1
  67. letta/server/rest_api/routers/v1/runs.py +2 -22
  68. letta/server/rest_api/routers/v1/tools.py +10 -0
  69. letta/server/server.py +5 -2
  70. letta/services/agent_manager.py +4 -4
  71. letta/services/archive_manager.py +16 -0
  72. letta/services/group_manager.py +44 -0
  73. letta/services/helpers/run_manager_helper.py +2 -2
  74. letta/services/lettuce/lettuce_client.py +148 -0
  75. letta/services/mcp/base_client.py +9 -3
  76. letta/services/run_manager.py +148 -37
  77. letta/services/source_manager.py +91 -3
  78. letta/services/step_manager.py +2 -3
  79. letta/services/streaming_service.py +52 -13
  80. letta/services/summarizer/summarizer.py +28 -2
  81. letta/services/tool_executor/builtin_tool_executor.py +1 -1
  82. letta/services/tool_executor/core_tool_executor.py +2 -117
  83. letta/services/tool_schema_generator.py +2 -2
  84. letta/validators.py +21 -0
  85. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/METADATA +1 -1
  86. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/RECORD +89 -84
  87. letta/agent.py +0 -1758
  88. letta/cli/cli_load.py +0 -16
  89. letta/client/__init__.py +0 -0
  90. letta/client/streaming.py +0 -95
  91. letta/client/utils.py +0 -78
  92. letta/functions/async_composio_toolset.py +0 -109
  93. letta/functions/composio_helpers.py +0 -96
  94. letta/helpers/composio_helpers.py +0 -38
  95. letta/orm/job_messages.py +0 -33
  96. letta/schemas/providers.py +0 -1617
  97. letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +0 -132
  98. letta/services/tool_executor/composio_tool_executor.py +0 -57
  99. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/WHEEL +0 -0
  100. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/entry_points.txt +0 -0
  101. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/licenses/LICENSE +0 -0
@@ -2,6 +2,7 @@ from typing import Optional
2
2
 
3
3
  from pydantic import Field
4
4
 
5
+ from letta.schemas.enums import PrimitiveType
5
6
  from letta.schemas.letta_base import LettaBase, OrmMetadataBase
6
7
  from letta.schemas.secret import Secret
7
8
  from letta.settings import settings
@@ -52,7 +53,7 @@ class EnvironmentVariableUpdateBase(LettaBase):
52
53
 
53
54
  # Environment Variable
54
55
  class SandboxEnvironmentVariableBase(EnvironmentVariableBase):
55
- __id_prefix__ = "sandbox-env"
56
+ __id_prefix__ = PrimitiveType.SANDBOX_ENV.value
56
57
  sandbox_config_id: str = Field(..., description="The ID of the sandbox config this environment variable belongs to.")
57
58
 
58
59
 
@@ -70,7 +71,7 @@ class SandboxEnvironmentVariableUpdate(EnvironmentVariableUpdateBase):
70
71
 
71
72
  # Agent-Specific Environment Variable
72
73
  class AgentEnvironmentVariableBase(EnvironmentVariableBase):
73
- __id_prefix__ = "agent-env"
74
+ __id_prefix__ = PrimitiveType.AGENT_ENV.value
74
75
  agent_id: str = Field(..., description="The ID of the agent this environment variable belongs to.")
75
76
 
76
77
 
letta/schemas/group.py CHANGED
@@ -34,7 +34,7 @@ class Group(GroupBase):
34
34
  template_id: Optional[str] = Field(None, description="The id of the template.")
35
35
  base_template_id: Optional[str] = Field(None, description="The base template id.")
36
36
  deployment_id: Optional[str] = Field(None, description="The id of the deployment.")
37
- shared_block_ids: List[str] = Field([], description="")
37
+ shared_block_ids: List[str] = Field([], description="", deprecated=True)
38
38
  # Pattern fields
39
39
  manager_agent_id: Optional[str] = Field(None, description="")
40
40
  termination_token: Optional[str] = Field(None, description="")
@@ -174,7 +174,7 @@ class GroupCreate(BaseModel):
174
174
  description: str = Field(..., description="")
175
175
  manager_config: ManagerConfigUnion = Field(RoundRobinManager(), description="")
176
176
  project_id: Optional[str] = Field(None, description="The associated project id.")
177
- shared_block_ids: List[str] = Field([], description="")
177
+ shared_block_ids: List[str] = Field([], description="", deprecated=True)
178
178
  hidden: Optional[bool] = Field(
179
179
  None,
180
180
  description="If set to True, the group will be hidden.",
@@ -194,4 +194,4 @@ class GroupUpdate(BaseModel):
194
194
  description: Optional[str] = Field(None, description="")
195
195
  manager_config: Optional[ManagerConfigUpdateUnion] = Field(None, description="")
196
196
  project_id: Optional[str] = Field(None, description="The associated project id.")
197
- shared_block_ids: Optional[List[str]] = Field(None, description="")
197
+ shared_block_ids: Optional[List[str]] = Field(None, description="", deprecated=True)
@@ -4,11 +4,24 @@ import re
4
4
  from datetime import datetime
5
5
  from typing import List, Union
6
6
 
7
- from pydantic import BaseModel, Field
7
+ from pydantic import BaseModel, Field, RootModel
8
8
 
9
9
  from letta.helpers.json_helpers import json_dumps
10
10
  from letta.schemas.enums import JobStatus, MessageStreamStatus
11
- from letta.schemas.letta_message import LettaMessage, LettaMessageUnion
11
+ from letta.schemas.letta_message import (
12
+ ApprovalRequestMessage,
13
+ ApprovalResponseMessage,
14
+ AssistantMessage,
15
+ HiddenReasoningMessage,
16
+ LettaMessage,
17
+ LettaMessageUnion,
18
+ LettaPing,
19
+ ReasoningMessage,
20
+ SystemMessage,
21
+ ToolCallMessage,
22
+ ToolReturnMessage,
23
+ UserMessage,
24
+ )
12
25
  from letta.schemas.letta_stop_reason import LettaStopReason
13
26
  from letta.schemas.message import Message
14
27
  from letta.schemas.usage import LettaUsageStatistics
@@ -170,8 +183,27 @@ class LettaResponse(BaseModel):
170
183
  return html_output
171
184
 
172
185
 
173
- # The streaming response is either [DONE], [DONE_STEP], [DONE], an error, or a LettaMessage
174
- LettaStreamingResponse = Union[LettaMessage, MessageStreamStatus, LettaStopReason, LettaUsageStatistics]
186
+ # The streaming response can be any of the individual message types, plus metadata types
187
+ class LettaStreamingResponse(RootModel):
188
+ """
189
+ Streaming response type for Server-Sent Events (SSE) endpoints.
190
+ Each event in the stream will be one of these types.
191
+ """
192
+
193
+ root: Union[
194
+ SystemMessage,
195
+ UserMessage,
196
+ ReasoningMessage,
197
+ HiddenReasoningMessage,
198
+ ToolCallMessage,
199
+ ToolReturnMessage,
200
+ AssistantMessage,
201
+ ApprovalRequestMessage,
202
+ ApprovalResponseMessage,
203
+ LettaPing,
204
+ LettaStopReason,
205
+ LettaUsageStatistics,
206
+ ] = Field(..., discriminator="message_type")
175
207
 
176
208
 
177
209
  class LettaBatchResponse(BaseModel):
@@ -5,7 +5,7 @@ from anthropic.types.beta.messages import BetaMessageBatch, BetaMessageBatchIndi
5
5
  from pydantic import BaseModel, Field
6
6
 
7
7
  from letta.helpers import ToolRulesSolver
8
- from letta.schemas.enums import AgentStepStatus, JobStatus, ProviderType
8
+ from letta.schemas.enums import AgentStepStatus, JobStatus, PrimitiveType, ProviderType
9
9
  from letta.schemas.letta_base import OrmMetadataBase
10
10
  from letta.schemas.llm_config import LLMConfig
11
11
 
@@ -16,7 +16,7 @@ class AgentStepState(BaseModel):
16
16
 
17
17
 
18
18
  class LLMBatchItemBase(OrmMetadataBase, validate_assignment=True):
19
- __id_prefix__ = "batch_item"
19
+ __id_prefix__ = PrimitiveType.BATCH_ITEM.value
20
20
 
21
21
 
22
22
  class LLMBatchItem(LLMBatchItemBase, validate_assignment=True):
@@ -47,7 +47,7 @@ class LLMBatchJob(OrmMetadataBase, validate_assignment=True):
47
47
  Each job corresponds to one API call that sends multiple messages to the LLM provider, and aggregates responses across all agent submissions.
48
48
  """
49
49
 
50
- __id_prefix__ = "batch_req"
50
+ __id_prefix__ = PrimitiveType.BATCH_REQUEST.value
51
51
 
52
52
  id: Optional[str] = Field(None, description="The id of the batch job. Assigned by the database.")
53
53
  status: JobStatus = Field(..., description="The current status of the batch (e.g., created, in_progress, done).")
@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Literal, Optional
3
3
  from pydantic import BaseModel, ConfigDict, Field, model_validator
4
4
 
5
5
  from letta.constants import LETTA_MODEL_ENDPOINT
6
+ from letta.errors import LettaInvalidArgumentError
6
7
  from letta.log import get_logger
7
8
  from letta.schemas.enums import AgentType, ProviderCategory
8
9
 
@@ -163,6 +164,24 @@ class LLMConfig(BaseModel):
163
164
 
164
165
  return values
165
166
 
167
+ @model_validator(mode="before")
168
+ @classmethod
169
+ def validate_codex_reasoning_effort(cls, values):
170
+ """
171
+ Validate that gpt-5-codex models do not use 'minimal' reasoning effort.
172
+ Codex models require at least 'low' reasoning effort.
173
+ """
174
+ from letta.llm_api.openai_client import does_not_support_minimal_reasoning
175
+
176
+ model = values.get("model")
177
+ reasoning_effort = values.get("reasoning_effort")
178
+
179
+ if model and does_not_support_minimal_reasoning(model) and reasoning_effort == "minimal":
180
+ raise LettaInvalidArgumentError(
181
+ f"Model '{model}' does not support 'minimal' reasoning effort. Please use 'low', 'medium', or 'high' instead."
182
+ )
183
+ return values
184
+
166
185
  @classmethod
167
186
  def default_config(cls, model_name: str):
168
187
  """
@@ -277,6 +296,8 @@ class LLMConfig(BaseModel):
277
296
  - Google Gemini (2.5 family): force disabled until native reasoning supported
278
297
  - All others: disabled (no simulated reasoning via kwargs)
279
298
  """
299
+ from letta.llm_api.openai_client import does_not_support_minimal_reasoning
300
+
280
301
  # V1 agent policy: do not allow simulated reasoning for non-native models
281
302
  if agent_type is not None and agent_type == AgentType.letta_v1_agent:
282
303
  # OpenAI native reasoning models: always on
@@ -284,7 +305,8 @@ class LLMConfig(BaseModel):
284
305
  config.put_inner_thoughts_in_kwargs = False
285
306
  config.enable_reasoner = True
286
307
  if config.reasoning_effort is None:
287
- if config.model.startswith("gpt-5"):
308
+ # Codex models cannot use "minimal" reasoning effort
309
+ if config.model.startswith("gpt-5") and not does_not_support_minimal_reasoning(config.model):
288
310
  config.reasoning_effort = "minimal"
289
311
  else:
290
312
  config.reasoning_effort = "medium"
@@ -324,7 +346,8 @@ class LLMConfig(BaseModel):
324
346
  config.enable_reasoner = True
325
347
  if config.reasoning_effort is None:
326
348
  # GPT-5 models default to minimal, others to medium
327
- if config.model.startswith("gpt-5"):
349
+ # Codex models cannot use "minimal" reasoning effort
350
+ if config.model.startswith("gpt-5") and not does_not_support_minimal_reasoning(config.model):
328
351
  config.reasoning_effort = "minimal"
329
352
  else:
330
353
  config.reasoning_effort = "medium"
@@ -357,7 +380,8 @@ class LLMConfig(BaseModel):
357
380
  config.put_inner_thoughts_in_kwargs = False
358
381
  if config.reasoning_effort is None:
359
382
  # GPT-5 models default to minimal, others to medium
360
- if config.model.startswith("gpt-5"):
383
+ # Codex models cannot use "minimal" reasoning effort
384
+ if config.model.startswith("gpt-5") and not does_not_support_minimal_reasoning(config.model):
361
385
  config.reasoning_effort = "minimal"
362
386
  else:
363
387
  config.reasoning_effort = "medium"
letta/schemas/mcp.py CHANGED
@@ -13,13 +13,14 @@ from letta.functions.mcp_client.types import (
13
13
  StreamableHTTPServerConfig,
14
14
  )
15
15
  from letta.orm.mcp_oauth import OAuthSessionStatus
16
+ from letta.schemas.enums import PrimitiveType
16
17
  from letta.schemas.letta_base import LettaBase
17
18
  from letta.schemas.secret import Secret
18
19
  from letta.settings import settings
19
20
 
20
21
 
21
22
  class BaseMCPServer(LettaBase):
22
- __id_prefix__ = "mcp_server"
23
+ __id_prefix__ = PrimitiveType.MCP_SERVER.value
23
24
 
24
25
 
25
26
  class MCPServer(BaseMCPServer):
@@ -178,7 +179,7 @@ UpdateMCPServer = Union[UpdateSSEMCPServer, UpdateStdioMCPServer, UpdateStreamab
178
179
 
179
180
  # OAuth-related schemas
180
181
  class BaseMCPOAuth(LettaBase):
181
- __id_prefix__ = "mcp-oauth"
182
+ __id_prefix__ = PrimitiveType.MCP_OAUTH.value
182
183
 
183
184
 
184
185
  class MCPOAuthSession(BaseMCPOAuth):
@@ -13,12 +13,13 @@ from letta.functions.mcp_client.types import (
13
13
  StreamableHTTPServerConfig,
14
14
  )
15
15
  from letta.orm.mcp_oauth import OAuthSessionStatus
16
+ from letta.schemas.enums import PrimitiveType
16
17
  from letta.schemas.letta_base import LettaBase
17
18
  from letta.schemas.secret import Secret
18
19
 
19
20
 
20
21
  class BaseMCPServer(LettaBase):
21
- __id_prefix__ = "mcp_server"
22
+ __id_prefix__ = PrimitiveType.MCP_SERVER.value
22
23
 
23
24
 
24
25
  # Create Schemas (for POST requests)
@@ -101,7 +102,7 @@ UpdateMCPServerUnion = Union[UpdateStdioMCPServer, UpdateSSEMCPServer, UpdateStr
101
102
 
102
103
  # OAuth-related schemas
103
104
  class BaseMCPOAuth(LettaBase):
104
- __id_prefix__ = "mcp-oauth"
105
+ __id_prefix__ = PrimitiveType.MCP_OAUTH.value
105
106
 
106
107
 
107
108
  class MCPOAuthSession(BaseMCPOAuth):
letta/schemas/message.py CHANGED
@@ -13,7 +13,6 @@ from datetime import datetime, timezone
13
13
  from enum import Enum
14
14
  from typing import Annotated, Any, Dict, List, Literal, Optional, Union
15
15
 
16
- from letta_client import LettaMessageUnion
17
16
  from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall, Function as OpenAIFunction
18
17
  from openai.types.responses import ResponseReasoningItem
19
18
  from pydantic import BaseModel, Field, field_validator, model_validator
@@ -57,6 +56,14 @@ from letta.system import unpack_message
57
56
  from letta.utils import parse_json, validate_function_response
58
57
 
59
58
 
59
+ def truncate_tool_return(content: Optional[str], limit: Optional[int]) -> Optional[str]:
60
+ if limit is None or content is None:
61
+ return content
62
+ if len(content) <= limit:
63
+ return content
64
+ return content[:limit] + f"... [truncated {len(content) - limit} chars]"
65
+
66
+
60
67
  def add_inner_thoughts_to_tool_call(
61
68
  tool_call: OpenAIToolCall,
62
69
  inner_thoughts: str,
@@ -1091,6 +1098,7 @@ class Message(BaseMessage):
1091
1098
  # if true, then treat the content field as AssistantMessage
1092
1099
  native_content: bool = False,
1093
1100
  strip_request_heartbeat: bool = False,
1101
+ tool_return_truncation_chars: Optional[int] = None,
1094
1102
  ) -> dict | None:
1095
1103
  """Go from Message class to ChatCompletion message object"""
1096
1104
  assert not (native_content and put_inner_thoughts_in_kwargs), "native_content and put_inner_thoughts_in_kwargs cannot both be true"
@@ -1139,8 +1147,14 @@ class Message(BaseMessage):
1139
1147
  assert self.tool_calls is not None or text_content is not None, vars(self)
1140
1148
  except AssertionError as e:
1141
1149
  # relax check if this message only contains reasoning content
1142
- if self.content is not None and len(self.content) > 0 and isinstance(self.content[0], ReasoningContent):
1143
- return None
1150
+ if self.content is not None and len(self.content) > 0:
1151
+ # Check if all non-empty content is reasoning-related
1152
+ all_reasoning = all(
1153
+ isinstance(c, (ReasoningContent, SummarizedReasoningContent, OmittedReasoningContent, RedactedReasoningContent))
1154
+ for c in self.content
1155
+ )
1156
+ if all_reasoning:
1157
+ return None
1144
1158
  raise e
1145
1159
 
1146
1160
  # if native content, then put it directly inside the content
@@ -1181,12 +1195,26 @@ class Message(BaseMessage):
1181
1195
  tool_call_dict["id"] = tool_call_dict["id"][:max_tool_id_length]
1182
1196
 
1183
1197
  elif self.role == "tool":
1184
- assert self.tool_call_id is not None, vars(self)
1185
- openai_message = {
1186
- "content": text_content,
1187
- "role": self.role,
1188
- "tool_call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1189
- }
1198
+ # Handle tool returns - if tool_returns exists, use the first one
1199
+ if self.tool_returns and len(self.tool_returns) > 0:
1200
+ tool_return = self.tool_returns[0]
1201
+ if not tool_return.tool_call_id:
1202
+ raise TypeError("OpenAI API requires tool_call_id to be set.")
1203
+ func_response = truncate_tool_return(tool_return.func_response, tool_return_truncation_chars)
1204
+ openai_message = {
1205
+ "content": func_response,
1206
+ "role": self.role,
1207
+ "tool_call_id": tool_return.tool_call_id[:max_tool_id_length] if max_tool_id_length else tool_return.tool_call_id,
1208
+ }
1209
+ else:
1210
+ # Legacy fallback for old message format
1211
+ assert self.tool_call_id is not None, vars(self)
1212
+ legacy_content = truncate_tool_return(text_content, tool_return_truncation_chars)
1213
+ openai_message = {
1214
+ "content": legacy_content,
1215
+ "role": self.role,
1216
+ "tool_call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1217
+ }
1190
1218
 
1191
1219
  else:
1192
1220
  raise ValueError(self.role)
@@ -1215,22 +1243,42 @@ class Message(BaseMessage):
1215
1243
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1216
1244
  put_inner_thoughts_in_kwargs: bool = False,
1217
1245
  use_developer_message: bool = False,
1246
+ tool_return_truncation_chars: Optional[int] = None,
1218
1247
  ) -> List[dict]:
1219
1248
  messages = Message.filter_messages_for_llm_api(messages)
1220
- result = [
1221
- m.to_openai_dict(
1249
+ result: List[dict] = []
1250
+
1251
+ for m in messages:
1252
+ # Special case: OpenAI Chat Completions requires a separate tool message per tool_call_id
1253
+ # If we have multiple explicit tool_returns on a single Message, expand into one dict per return
1254
+ if m.role == MessageRole.tool and m.tool_returns and len(m.tool_returns) > 0:
1255
+ for tr in m.tool_returns:
1256
+ if not tr.tool_call_id:
1257
+ raise TypeError("ToolReturn came back without a tool_call_id.")
1258
+ result.append(
1259
+ {
1260
+ "content": tr.func_response,
1261
+ "role": "tool",
1262
+ "tool_call_id": tr.tool_call_id[:max_tool_id_length] if max_tool_id_length else tr.tool_call_id,
1263
+ }
1264
+ )
1265
+ continue
1266
+
1267
+ d = m.to_openai_dict(
1222
1268
  max_tool_id_length=max_tool_id_length,
1223
1269
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1224
1270
  use_developer_message=use_developer_message,
1271
+ tool_return_truncation_chars=tool_return_truncation_chars,
1225
1272
  )
1226
- for m in messages
1227
- ]
1228
- result = [m for m in result if m is not None]
1273
+ if d is not None:
1274
+ result.append(d)
1275
+
1229
1276
  return result
1230
1277
 
1231
1278
  def to_openai_responses_dicts(
1232
1279
  self,
1233
1280
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1281
+ tool_return_truncation_chars: Optional[int] = None,
1234
1282
  ) -> List[dict]:
1235
1283
  """Go from Message class to ChatCompletion message object"""
1236
1284
 
@@ -1306,15 +1354,31 @@ class Message(BaseMessage):
1306
1354
  )
1307
1355
 
1308
1356
  elif self.role == "tool":
1309
- assert self.tool_call_id is not None, vars(self)
1310
- assert len(self.content) == 1 and isinstance(self.content[0], TextContent), vars(self)
1311
- message_dicts.append(
1312
- {
1313
- "type": "function_call_output",
1314
- "call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1315
- "output": self.content[0].text,
1316
- }
1317
- )
1357
+ # Handle tool returns - similar pattern to Anthropic
1358
+ if self.tool_returns:
1359
+ for tool_return in self.tool_returns:
1360
+ if not tool_return.tool_call_id:
1361
+ raise TypeError("OpenAI Responses API requires tool_call_id to be set.")
1362
+ func_response = truncate_tool_return(tool_return.func_response, tool_return_truncation_chars)
1363
+ message_dicts.append(
1364
+ {
1365
+ "type": "function_call_output",
1366
+ "call_id": tool_return.tool_call_id[:max_tool_id_length] if max_tool_id_length else tool_return.tool_call_id,
1367
+ "output": func_response,
1368
+ }
1369
+ )
1370
+ else:
1371
+ # Legacy fallback for old message format
1372
+ assert self.tool_call_id is not None, vars(self)
1373
+ assert len(self.content) == 1 and isinstance(self.content[0], TextContent), vars(self)
1374
+ legacy_output = truncate_tool_return(self.content[0].text, tool_return_truncation_chars)
1375
+ message_dicts.append(
1376
+ {
1377
+ "type": "function_call_output",
1378
+ "call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1379
+ "output": legacy_output,
1380
+ }
1381
+ )
1318
1382
 
1319
1383
  else:
1320
1384
  raise ValueError(self.role)
@@ -1325,11 +1389,16 @@ class Message(BaseMessage):
1325
1389
  def to_openai_responses_dicts_from_list(
1326
1390
  messages: List[Message],
1327
1391
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1392
+ tool_return_truncation_chars: Optional[int] = None,
1328
1393
  ) -> List[dict]:
1329
1394
  messages = Message.filter_messages_for_llm_api(messages)
1330
1395
  result = []
1331
1396
  for message in messages:
1332
- result.extend(message.to_openai_responses_dicts(max_tool_id_length=max_tool_id_length))
1397
+ result.extend(
1398
+ message.to_openai_responses_dicts(
1399
+ max_tool_id_length=max_tool_id_length, tool_return_truncation_chars=tool_return_truncation_chars
1400
+ )
1401
+ )
1333
1402
  return result
1334
1403
 
1335
1404
  def to_anthropic_dict(
@@ -1340,6 +1409,7 @@ class Message(BaseMessage):
1340
1409
  # if true, then treat the content field as AssistantMessage
1341
1410
  native_content: bool = False,
1342
1411
  strip_request_heartbeat: bool = False,
1412
+ tool_return_truncation_chars: Optional[int] = None,
1343
1413
  ) -> dict | None:
1344
1414
  """
1345
1415
  Convert to an Anthropic message dictionary
@@ -1515,11 +1585,12 @@ class Message(BaseMessage):
1515
1585
  for tool_return in self.tool_returns:
1516
1586
  if not tool_return.tool_call_id:
1517
1587
  raise TypeError("Anthropic API requires tool_use_id to be set.")
1588
+ func_response = truncate_tool_return(tool_return.func_response, tool_return_truncation_chars)
1518
1589
  content.append(
1519
1590
  {
1520
1591
  "type": "tool_result",
1521
1592
  "tool_use_id": tool_return.tool_call_id,
1522
- "content": tool_return.func_response,
1593
+ "content": func_response,
1523
1594
  }
1524
1595
  )
1525
1596
  if content:
@@ -1532,6 +1603,7 @@ class Message(BaseMessage):
1532
1603
  raise TypeError("Anthropic API requires tool_use_id to be set.")
1533
1604
 
1534
1605
  # This is for legacy reasons
1606
+ legacy_content = truncate_tool_return(text_content, tool_return_truncation_chars)
1535
1607
  anthropic_message = {
1536
1608
  "role": "user", # NOTE: diff
1537
1609
  "content": [
@@ -1539,7 +1611,7 @@ class Message(BaseMessage):
1539
1611
  {
1540
1612
  "type": "tool_result",
1541
1613
  "tool_use_id": self.tool_call_id,
1542
- "content": text_content,
1614
+ "content": legacy_content,
1543
1615
  }
1544
1616
  ],
1545
1617
  }
@@ -1558,6 +1630,7 @@ class Message(BaseMessage):
1558
1630
  # if true, then treat the content field as AssistantMessage
1559
1631
  native_content: bool = False,
1560
1632
  strip_request_heartbeat: bool = False,
1633
+ tool_return_truncation_chars: Optional[int] = None,
1561
1634
  ) -> List[dict]:
1562
1635
  messages = Message.filter_messages_for_llm_api(messages)
1563
1636
  result = [
@@ -1567,6 +1640,7 @@ class Message(BaseMessage):
1567
1640
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1568
1641
  native_content=native_content,
1569
1642
  strip_request_heartbeat=strip_request_heartbeat,
1643
+ tool_return_truncation_chars=tool_return_truncation_chars,
1570
1644
  )
1571
1645
  for m in messages
1572
1646
  ]
@@ -1580,6 +1654,7 @@ class Message(BaseMessage):
1580
1654
  # if true, then treat the content field as AssistantMessage
1581
1655
  native_content: bool = False,
1582
1656
  strip_request_heartbeat: bool = False,
1657
+ tool_return_truncation_chars: Optional[int] = None,
1583
1658
  ) -> dict | None:
1584
1659
  """
1585
1660
  Go from Message class to Google AI REST message object
@@ -1717,34 +1792,75 @@ class Message(BaseMessage):
1717
1792
 
1718
1793
  elif self.role == "tool":
1719
1794
  # NOTE: Significantly different tool calling format, more similar to function calling format
1720
- assert self.tool_call_id is not None, vars(self)
1721
1795
 
1722
- if self.name is None:
1723
- logger.warning("Couldn't find function name on tool call, defaulting to tool ID instead.")
1724
- function_name = self.tool_call_id
1796
+ # Handle tool returns - similar pattern to Anthropic
1797
+ if self.tool_returns:
1798
+ parts = []
1799
+ for tool_return in self.tool_returns:
1800
+ if not tool_return.tool_call_id:
1801
+ raise TypeError("Google AI API requires tool_call_id to be set.")
1802
+
1803
+ # Use the function name if available, otherwise use tool_call_id
1804
+ function_name = self.name if self.name else tool_return.tool_call_id
1805
+
1806
+ # Truncate the tool return if needed
1807
+ func_response = truncate_tool_return(tool_return.func_response, tool_return_truncation_chars)
1808
+
1809
+ # NOTE: Google AI API wants the function response as JSON only, no string
1810
+ try:
1811
+ function_response = parse_json(func_response)
1812
+ except:
1813
+ function_response = {"function_response": func_response}
1814
+
1815
+ parts.append(
1816
+ {
1817
+ "functionResponse": {
1818
+ "name": function_name,
1819
+ "response": {
1820
+ "name": function_name, # NOTE: name twice... why?
1821
+ "content": function_response,
1822
+ },
1823
+ }
1824
+ }
1825
+ )
1826
+
1827
+ google_ai_message = {
1828
+ "role": "function",
1829
+ "parts": parts,
1830
+ }
1725
1831
  else:
1726
- function_name = self.name
1832
+ # Legacy fallback for old message format
1833
+ assert self.tool_call_id is not None, vars(self)
1727
1834
 
1728
- # NOTE: Google AI API wants the function response as JSON only, no string
1729
- try:
1730
- function_response = parse_json(text_content)
1731
- except:
1732
- function_response = {"function_response": text_content}
1835
+ if self.name is None:
1836
+ logger.warning("Couldn't find function name on tool call, defaulting to tool ID instead.")
1837
+ function_name = self.tool_call_id
1838
+ else:
1839
+ function_name = self.name
1733
1840
 
1734
- google_ai_message = {
1735
- "role": "function",
1736
- "parts": [
1737
- {
1738
- "functionResponse": {
1739
- "name": function_name,
1740
- "response": {
1741
- "name": function_name, # NOTE: name twice... why?
1742
- "content": function_response,
1743
- },
1841
+ # Truncate the legacy content if needed
1842
+ legacy_content = truncate_tool_return(text_content, tool_return_truncation_chars)
1843
+
1844
+ # NOTE: Google AI API wants the function response as JSON only, no string
1845
+ try:
1846
+ function_response = parse_json(legacy_content)
1847
+ except:
1848
+ function_response = {"function_response": legacy_content}
1849
+
1850
+ google_ai_message = {
1851
+ "role": "function",
1852
+ "parts": [
1853
+ {
1854
+ "functionResponse": {
1855
+ "name": function_name,
1856
+ "response": {
1857
+ "name": function_name, # NOTE: name twice... why?
1858
+ "content": function_response,
1859
+ },
1860
+ }
1744
1861
  }
1745
- }
1746
- ],
1747
- }
1862
+ ],
1863
+ }
1748
1864
 
1749
1865
  else:
1750
1866
  raise ValueError(self.role)
@@ -1765,6 +1881,7 @@ class Message(BaseMessage):
1765
1881
  current_model: str,
1766
1882
  put_inner_thoughts_in_kwargs: bool = True,
1767
1883
  native_content: bool = False,
1884
+ tool_return_truncation_chars: Optional[int] = None,
1768
1885
  ):
1769
1886
  messages = Message.filter_messages_for_llm_api(messages)
1770
1887
  result = [
@@ -1772,6 +1889,7 @@ class Message(BaseMessage):
1772
1889
  current_model=current_model,
1773
1890
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1774
1891
  native_content=native_content,
1892
+ tool_return_truncation_chars=tool_return_truncation_chars,
1775
1893
  )
1776
1894
  for m in messages
1777
1895
  ]
@@ -4,12 +4,13 @@ from typing import Optional
4
4
  from pydantic import Field
5
5
 
6
6
  from letta.helpers.datetime_helpers import get_utc_time
7
+ from letta.schemas.enums import PrimitiveType
7
8
  from letta.schemas.letta_base import LettaBase
8
9
  from letta.utils import create_random_username
9
10
 
10
11
 
11
12
  class OrganizationBase(LettaBase):
12
- __id_prefix__ = "org"
13
+ __id_prefix__ = PrimitiveType.ORGANIZATION.value
13
14
 
14
15
 
15
16
  class Organization(OrganizationBase):
letta/schemas/passage.py CHANGED
@@ -6,11 +6,12 @@ from pydantic import Field, field_validator
6
6
  from letta.constants import MAX_EMBEDDING_DIM
7
7
  from letta.helpers.datetime_helpers import get_utc_time
8
8
  from letta.schemas.embedding_config import EmbeddingConfig
9
+ from letta.schemas.enums import PrimitiveType
9
10
  from letta.schemas.letta_base import OrmMetadataBase
10
11
 
11
12
 
12
13
  class PassageBase(OrmMetadataBase):
13
- __id_prefix__ = "passage"
14
+ __id_prefix__ = PrimitiveType.PASSAGE.value
14
15
 
15
16
  is_deleted: bool = Field(False, description="Whether this passage is deleted or not.")
16
17
 
@@ -6,11 +6,12 @@ from typing import Any, Dict, Optional
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  from letta.helpers.datetime_helpers import get_utc_time
9
+ from letta.schemas.enums import PrimitiveType
9
10
  from letta.schemas.letta_base import OrmMetadataBase
10
11
 
11
12
 
12
13
  class BaseProviderTrace(OrmMetadataBase):
13
- __id_prefix__ = "provider_trace"
14
+ __id_prefix__ = PrimitiveType.PROVIDER_TRACE.value
14
15
 
15
16
 
16
17
  class ProviderTraceCreate(BaseModel):