letta-nightly 0.12.0.dev20251009104148__py3-none-any.whl → 0.12.1.dev20251009224219__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.
letta/schemas/secret.py CHANGED
@@ -2,6 +2,7 @@ import json
2
2
  from typing import Any, Dict, Optional
3
3
 
4
4
  from pydantic import BaseModel, ConfigDict, PrivateAttr
5
+ from pydantic_core import core_schema
5
6
 
6
7
  from letta.helpers.crypto_utils import CryptoUtils
7
8
  from letta.log import get_logger
@@ -19,16 +20,16 @@ class Secret(BaseModel):
19
20
  TODO: Once we deprecate plaintext columns in the database:
20
21
  - Remove the dual-write logic in to_dict()
21
22
  - Remove the from_db() method's plaintext_value parameter
22
- - Remove the _was_encrypted flag (no longer needed for migration)
23
+ - Remove the was_encrypted flag (no longer needed for migration)
23
24
  - Simplify get_plaintext() to only handle encrypted values
24
25
  """
25
26
 
26
- # Store the encrypted value
27
- _encrypted_value: Optional[str] = PrivateAttr(default=None)
28
- # Cache the decrypted value to avoid repeated decryption
27
+ # Store the encrypted value as a regular field
28
+ encrypted_value: Optional[str] = None
29
+ # Cache the decrypted value to avoid repeated decryption (not serialized for security)
29
30
  _plaintext_cache: Optional[str] = PrivateAttr(default=None)
30
31
  # Flag to indicate if the value was originally encrypted
31
- _was_encrypted: bool = PrivateAttr(default=False)
32
+ was_encrypted: bool = False
32
33
 
33
34
  model_config = ConfigDict(frozen=True)
34
35
 
@@ -44,18 +45,16 @@ class Secret(BaseModel):
44
45
  A Secret instance with the encrypted value, or plaintext if encryption unavailable
45
46
  """
46
47
  if value is None:
47
- instance = cls()
48
- instance._encrypted_value = None
49
- instance._was_encrypted = False
50
- return instance
48
+ return cls.model_construct(encrypted_value=None, was_encrypted=False)
49
+
50
+ # Guard against double encryption - check if value is already encrypted
51
+ if CryptoUtils.is_encrypted(value):
52
+ logger.warning("Creating Secret from already-encrypted value. This can be dangerous.")
51
53
 
52
54
  # Try to encrypt, but fall back to plaintext if no encryption key
53
55
  try:
54
56
  encrypted = CryptoUtils.encrypt(value)
55
- instance = cls()
56
- instance._encrypted_value = encrypted
57
- instance._was_encrypted = False
58
- return instance
57
+ return cls.model_construct(encrypted_value=encrypted, was_encrypted=False)
59
58
  except ValueError as e:
60
59
  # No encryption key available, store as plaintext
61
60
  if "No encryption key configured" in str(e):
@@ -63,10 +62,8 @@ class Secret(BaseModel):
63
62
  "No encryption key configured. Storing Secret value as plaintext. "
64
63
  "Set LETTA_ENCRYPTION_KEY environment variable to enable encryption."
65
64
  )
66
- instance = cls()
67
- instance._encrypted_value = value # Store plaintext
65
+ instance = cls.model_construct(encrypted_value=value, was_encrypted=False)
68
66
  instance._plaintext_cache = value # Cache it
69
- instance._was_encrypted = False
70
67
  return instance
71
68
  raise # Re-raise if it's a different error
72
69
 
@@ -81,10 +78,7 @@ class Secret(BaseModel):
81
78
  Returns:
82
79
  A Secret instance
83
80
  """
84
- instance = cls()
85
- instance._encrypted_value = encrypted_value
86
- instance._was_encrypted = True
87
- return instance
81
+ return cls.model_construct(encrypted_value=encrypted_value, was_encrypted=True)
88
82
 
89
83
  @classmethod
90
84
  def from_db(cls, encrypted_value: Optional[str], plaintext_value: Optional[str]) -> "Secret":
@@ -114,7 +108,7 @@ class Secret(BaseModel):
114
108
  Returns:
115
109
  The encrypted value, or None if the secret is empty
116
110
  """
117
- return self._encrypted_value
111
+ return self.encrypted_value
118
112
 
119
113
  def get_plaintext(self) -> Optional[str]:
120
114
  """
@@ -126,7 +120,7 @@ class Secret(BaseModel):
126
120
  Returns:
127
121
  The decrypted plaintext value
128
122
  """
129
- if self._encrypted_value is None:
123
+ if self.encrypted_value is None:
130
124
  return None
131
125
 
132
126
  # Use cached value if available, but only if it looks like plaintext
@@ -134,14 +128,14 @@ class Secret(BaseModel):
134
128
  if self._plaintext_cache is not None:
135
129
  # If we have a cache but the stored value looks encrypted and we have no key,
136
130
  # we should not use the cache
137
- if CryptoUtils.is_encrypted(self._encrypted_value) and not CryptoUtils.is_encryption_available():
131
+ if CryptoUtils.is_encrypted(self.encrypted_value) and not CryptoUtils.is_encryption_available():
138
132
  self._plaintext_cache = None # Clear invalid cache
139
133
  else:
140
134
  return self._plaintext_cache
141
135
 
142
136
  # Decrypt and cache
143
137
  try:
144
- plaintext = CryptoUtils.decrypt(self._encrypted_value)
138
+ plaintext = CryptoUtils.decrypt(self.encrypted_value)
145
139
  # Cache the decrypted value (PrivateAttr fields can be mutated even with frozen=True)
146
140
  self._plaintext_cache = plaintext
147
141
  return plaintext
@@ -151,7 +145,7 @@ class Secret(BaseModel):
151
145
  # Handle missing encryption key
152
146
  if "No encryption key configured" in error_msg:
153
147
  # Check if the value looks encrypted
154
- if CryptoUtils.is_encrypted(self._encrypted_value):
148
+ if CryptoUtils.is_encrypted(self.encrypted_value):
155
149
  # Value was encrypted, but now we have no key - can't decrypt
156
150
  logger.warning(
157
151
  "Cannot decrypt Secret value - no encryption key configured. "
@@ -162,26 +156,26 @@ class Secret(BaseModel):
162
156
  else:
163
157
  # Value is plaintext (stored when no key was available)
164
158
  logger.debug("Secret value is plaintext (stored without encryption)")
165
- self._plaintext_cache = self._encrypted_value
166
- return self._encrypted_value
159
+ self._plaintext_cache = self.encrypted_value
160
+ return self.encrypted_value
167
161
 
168
162
  # Handle decryption failure (might be plaintext stored as such)
169
163
  elif "Failed to decrypt data" in error_msg:
170
164
  # Check if it might be plaintext
171
- if not CryptoUtils.is_encrypted(self._encrypted_value):
165
+ if not CryptoUtils.is_encrypted(self.encrypted_value):
172
166
  # It's plaintext that was stored when no key was available
173
167
  logger.debug("Secret value appears to be plaintext (stored without encryption)")
174
- self._plaintext_cache = self._encrypted_value
175
- return self._encrypted_value
168
+ self._plaintext_cache = self.encrypted_value
169
+ return self.encrypted_value
176
170
  # Otherwise, it's corrupted or wrong key
177
171
  logger.error("Failed to decrypt Secret value - data may be corrupted or wrong key")
178
172
  raise
179
173
 
180
174
  # Migration case: handle legacy plaintext
181
- elif not self._was_encrypted:
182
- if self._encrypted_value and not CryptoUtils.is_encrypted(self._encrypted_value):
183
- self._plaintext_cache = self._encrypted_value
184
- return self._encrypted_value
175
+ elif not self.was_encrypted:
176
+ if self.encrypted_value and not CryptoUtils.is_encrypted(self.encrypted_value):
177
+ self._plaintext_cache = self.encrypted_value
178
+ return self.encrypted_value
185
179
  return None
186
180
 
187
181
  # Re-raise for other errors
@@ -189,7 +183,7 @@ class Secret(BaseModel):
189
183
 
190
184
  def is_empty(self) -> bool:
191
185
  """Check if the secret is empty/None."""
192
- return self._encrypted_value is None
186
+ return self.encrypted_value is None
193
187
 
194
188
  def __str__(self) -> str:
195
189
  """String representation that doesn't expose the actual value."""
@@ -207,7 +201,7 @@ class Secret(BaseModel):
207
201
 
208
202
  Returns both encrypted and plaintext values for dual-write during migration.
209
203
  """
210
- return {"encrypted": self.get_encrypted(), "plaintext": self.get_plaintext() if not self._was_encrypted else None}
204
+ return {"encrypted": self.get_encrypted(), "plaintext": self.get_plaintext() if not self.was_encrypted else None}
211
205
 
212
206
  def __eq__(self, other: Any) -> bool:
213
207
  """
@@ -219,6 +213,79 @@ class Secret(BaseModel):
219
213
  return False
220
214
  return self.get_plaintext() == other.get_plaintext()
221
215
 
216
+ @classmethod
217
+ def __get_pydantic_core_schema__(cls, source_type: Any, handler) -> core_schema.CoreSchema:
218
+ """
219
+ Customize Pydantic's validation and serialization behavior for Secret fields.
220
+
221
+ This allows Secret fields to automatically:
222
+ - Deserialize: Convert encrypted strings from DB → Secret objects
223
+ - Serialize: Convert Secret objects → encrypted strings for DB
224
+ """
225
+
226
+ def validate_secret(value: Any) -> "Secret":
227
+ """Convert various input types to Secret objects."""
228
+ if isinstance(value, Secret):
229
+ return value
230
+ elif isinstance(value, str):
231
+ # String from DB is assumed to be encrypted
232
+ return Secret.from_encrypted(value)
233
+ elif isinstance(value, dict):
234
+ # Dict might be from Pydantic serialization - check for encrypted_value key
235
+ if "encrypted_value" in value:
236
+ # This is a serialized Secret being deserialized
237
+ return cls(**value)
238
+ elif not value or value == {}:
239
+ # Empty dict means None
240
+ return Secret.from_plaintext(None)
241
+ else:
242
+ raise ValueError(f"Cannot convert dict to Secret: {value}")
243
+ elif value is None:
244
+ return Secret.from_plaintext(None)
245
+ else:
246
+ raise ValueError(f"Cannot convert {type(value)} to Secret")
247
+
248
+ def serialize_secret(secret: "Secret") -> Optional[str]:
249
+ """Serialize Secret to encrypted string."""
250
+ if secret is None:
251
+ return None
252
+ return secret.get_encrypted()
253
+
254
+ python_schema = core_schema.chain_schema(
255
+ [
256
+ core_schema.no_info_plain_validator_function(validate_secret),
257
+ core_schema.is_instance_schema(cls),
258
+ ]
259
+ )
260
+
261
+ return core_schema.json_or_python_schema(
262
+ json_schema=python_schema,
263
+ python_schema=python_schema,
264
+ serialization=core_schema.plain_serializer_function_ser_schema(
265
+ serialize_secret,
266
+ when_used="always",
267
+ ),
268
+ )
269
+
270
+ @classmethod
271
+ def __get_pydantic_json_schema__(cls, core_schema: core_schema.CoreSchema, handler) -> Dict[str, Any]:
272
+ """
273
+ Define JSON schema representation for Secret fields.
274
+ In JSON schema (OpenAPI docs), Secret fields appear as nullable strings.
275
+ The actual encryption/decryption happens at runtime via __get_pydantic_core_schema__.
276
+ Args:
277
+ core_schema: The core schema for this type
278
+ handler: Handler for generating JSON schema
279
+ Returns:
280
+ A JSON schema dict representing this type as a nullable string
281
+ """
282
+ # Return a simple string schema for JSON schema generation
283
+ return {
284
+ "type": "string",
285
+ "nullable": True,
286
+ "description": "Encrypted secret value (stored as encrypted string)",
287
+ }
288
+
222
289
 
223
290
  class SecretDict(BaseModel):
224
291
  """
@@ -562,14 +562,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
562
562
 
563
563
  if prev_message_type and prev_message_type != "tool_call_message":
564
564
  message_index += 1
565
+ tool_call_delta = ToolCallDelta(
566
+ name=json_reasoning_content.get("name"),
567
+ arguments=json.dumps(json_reasoning_content.get("arguments")),
568
+ tool_call_id=None,
569
+ )
565
570
  processed_chunk = ToolCallMessage(
566
571
  id=message_id,
567
572
  date=message_date,
568
- tool_call=ToolCallDelta(
569
- name=json_reasoning_content.get("name"),
570
- arguments=json.dumps(json_reasoning_content.get("arguments")),
571
- tool_call_id=None,
572
- ),
573
+ tool_call=tool_call_delta,
574
+ tool_calls=tool_call_delta,
573
575
  name=name,
574
576
  otid=Message.generate_otid_from_id(message_id, message_index),
575
577
  )
@@ -703,14 +705,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
703
705
  else:
704
706
  if prev_message_type and prev_message_type != "tool_call_message":
705
707
  message_index += 1
708
+ tc_delta = ToolCallDelta(
709
+ name=tool_call_delta.get("name"),
710
+ arguments=tool_call_delta.get("arguments"),
711
+ tool_call_id=tool_call_delta.get("id"),
712
+ )
706
713
  processed_chunk = ToolCallMessage(
707
714
  id=message_id,
708
715
  date=message_date,
709
- tool_call=ToolCallDelta(
710
- name=tool_call_delta.get("name"),
711
- arguments=tool_call_delta.get("arguments"),
712
- tool_call_id=tool_call_delta.get("id"),
713
- ),
716
+ tool_call=tc_delta,
717
+ tool_calls=tc_delta,
714
718
  name=name,
715
719
  otid=Message.generate_otid_from_id(message_id, message_index),
716
720
  )
@@ -779,14 +783,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
779
783
  else:
780
784
  if prev_message_type and prev_message_type != "tool_call_message":
781
785
  message_index += 1
786
+ tc_delta = ToolCallDelta(
787
+ name=self.function_name_buffer,
788
+ arguments=None,
789
+ tool_call_id=self.function_id_buffer,
790
+ )
782
791
  processed_chunk = ToolCallMessage(
783
792
  id=message_id,
784
793
  date=message_date,
785
- tool_call=ToolCallDelta(
786
- name=self.function_name_buffer,
787
- arguments=None,
788
- tool_call_id=self.function_id_buffer,
789
- ),
794
+ tool_call=tc_delta,
795
+ tool_calls=tc_delta,
790
796
  name=name,
791
797
  otid=Message.generate_otid_from_id(message_id, message_index),
792
798
  )
@@ -843,14 +849,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
843
849
  combined_chunk = self.function_args_buffer + updates_main_json
844
850
  if prev_message_type and prev_message_type != "tool_call_message":
845
851
  message_index += 1
852
+ tc_delta = ToolCallDelta(
853
+ name=None,
854
+ arguments=combined_chunk,
855
+ tool_call_id=self.function_id_buffer,
856
+ )
846
857
  processed_chunk = ToolCallMessage(
847
858
  id=message_id,
848
859
  date=message_date,
849
- tool_call=ToolCallDelta(
850
- name=None,
851
- arguments=combined_chunk,
852
- tool_call_id=self.function_id_buffer,
853
- ),
860
+ tool_call=tc_delta,
861
+ tool_calls=tc_delta,
854
862
  name=name,
855
863
  otid=Message.generate_otid_from_id(message_id, message_index),
856
864
  )
@@ -861,14 +869,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
861
869
  # If there's no buffer to clear, just output a new chunk with new data
862
870
  if prev_message_type and prev_message_type != "tool_call_message":
863
871
  message_index += 1
872
+ tc_delta = ToolCallDelta(
873
+ name=None,
874
+ arguments=updates_main_json,
875
+ tool_call_id=self.function_id_buffer,
876
+ )
864
877
  processed_chunk = ToolCallMessage(
865
878
  id=message_id,
866
879
  date=message_date,
867
- tool_call=ToolCallDelta(
868
- name=None,
869
- arguments=updates_main_json,
870
- tool_call_id=self.function_id_buffer,
871
- ),
880
+ tool_call=tc_delta,
881
+ tool_calls=tc_delta,
872
882
  name=name,
873
883
  otid=Message.generate_otid_from_id(message_id, message_index),
874
884
  )
@@ -992,14 +1002,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
992
1002
  else:
993
1003
  if prev_message_type and prev_message_type != "tool_call_message":
994
1004
  message_index += 1
1005
+ tc_delta = ToolCallDelta(
1006
+ name=tool_call_delta.get("name"),
1007
+ arguments=tool_call_delta.get("arguments"),
1008
+ tool_call_id=tool_call_delta.get("id"),
1009
+ )
995
1010
  processed_chunk = ToolCallMessage(
996
1011
  id=message_id,
997
1012
  date=message_date,
998
- tool_call=ToolCallDelta(
999
- name=tool_call_delta.get("name"),
1000
- arguments=tool_call_delta.get("arguments"),
1001
- tool_call_id=tool_call_delta.get("id"),
1002
- ),
1013
+ tool_call=tc_delta,
1014
+ tool_calls=tc_delta,
1003
1015
  name=name,
1004
1016
  otid=Message.generate_otid_from_id(message_id, message_index),
1005
1017
  )
@@ -1262,14 +1274,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
1262
1274
  # Store the ID of the tool call so allow skipping the corresponding response
1263
1275
  self.prev_assistant_message_id = function_call.id
1264
1276
  else:
1277
+ tool_call_obj = ToolCall(
1278
+ name=function_call.function.name,
1279
+ arguments=function_call.function.arguments,
1280
+ tool_call_id=function_call.id,
1281
+ )
1265
1282
  processed_chunk = ToolCallMessage(
1266
1283
  id=msg_obj.id,
1267
1284
  date=msg_obj.created_at,
1268
- tool_call=ToolCall(
1269
- name=function_call.function.name,
1270
- arguments=function_call.function.arguments,
1271
- tool_call_id=function_call.id,
1272
- ),
1285
+ tool_call=tool_call_obj,
1286
+ tool_calls=tool_call_obj,
1273
1287
  name=msg_obj.name,
1274
1288
  otid=Message.generate_otid_from_id(msg_obj.id, chunk_index) if chunk_index is not None else None,
1275
1289
  )
@@ -1303,14 +1317,29 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
1303
1317
  # Skip this tool call receipt
1304
1318
  return
1305
1319
  else:
1320
+ from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
1321
+
1322
+ status = msg_obj.tool_returns[0].status if msg_obj.tool_returns else "success"
1323
+ stdout = msg_obj.tool_returns[0].stdout if msg_obj.tool_returns else []
1324
+ stderr = msg_obj.tool_returns[0].stderr if msg_obj.tool_returns else []
1325
+
1326
+ tool_return_obj = ToolReturnSchema(
1327
+ tool_return=msg,
1328
+ status=status,
1329
+ tool_call_id=msg_obj.tool_call_id,
1330
+ stdout=stdout,
1331
+ stderr=stderr,
1332
+ )
1333
+
1306
1334
  new_message = ToolReturnMessage(
1307
1335
  id=msg_obj.id,
1308
1336
  date=msg_obj.created_at,
1309
1337
  tool_return=msg,
1310
- status=msg_obj.tool_returns[0].status if msg_obj.tool_returns else "success",
1338
+ status=status,
1311
1339
  tool_call_id=msg_obj.tool_call_id,
1312
- stdout=msg_obj.tool_returns[0].stdout if msg_obj.tool_returns else [],
1313
- stderr=msg_obj.tool_returns[0].stderr if msg_obj.tool_returns else [],
1340
+ stdout=stdout,
1341
+ stderr=stderr,
1342
+ tool_returns=[tool_return_obj],
1314
1343
  name=msg_obj.name,
1315
1344
  otid=Message.generate_otid_from_id(msg_obj.id, chunk_index) if chunk_index is not None else None,
1316
1345
  )
@@ -1319,14 +1348,29 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
1319
1348
  msg = msg.replace("Error: ", "", 1)
1320
1349
  # new_message = {"function_return": msg, "status": "error"}
1321
1350
  assert msg_obj.tool_call_id is not None
1351
+ from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
1352
+
1353
+ status = msg_obj.tool_returns[0].status if msg_obj.tool_returns else "error"
1354
+ stdout = msg_obj.tool_returns[0].stdout if msg_obj.tool_returns else []
1355
+ stderr = msg_obj.tool_returns[0].stderr if msg_obj.tool_returns else []
1356
+
1357
+ tool_return_obj = ToolReturnSchema(
1358
+ tool_return=msg,
1359
+ status=status,
1360
+ tool_call_id=msg_obj.tool_call_id,
1361
+ stdout=stdout,
1362
+ stderr=stderr,
1363
+ )
1364
+
1322
1365
  new_message = ToolReturnMessage(
1323
1366
  id=msg_obj.id,
1324
1367
  date=msg_obj.created_at,
1325
1368
  tool_return=msg,
1326
- status=msg_obj.tool_returns[0].status if msg_obj.tool_returns else "error",
1369
+ status=status,
1327
1370
  tool_call_id=msg_obj.tool_call_id,
1328
- stdout=msg_obj.tool_returns[0].stdout if msg_obj.tool_returns else [],
1329
- stderr=msg_obj.tool_returns[0].stderr if msg_obj.tool_returns else [],
1371
+ stdout=stdout,
1372
+ stderr=stderr,
1373
+ tool_returns=[tool_return_obj],
1330
1374
  name=msg_obj.name,
1331
1375
  otid=Message.generate_otid_from_id(msg_obj.id, chunk_index) if chunk_index is not None else None,
1332
1376
  )
@@ -120,6 +120,40 @@ async def check_provider(
120
120
  raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"{e}")
121
121
 
122
122
 
123
+ @router.post("/{provider_id}/check", response_model=None, operation_id="check_existing_provider")
124
+ async def check_existing_provider(
125
+ provider_id: str,
126
+ headers: HeaderParams = Depends(get_headers),
127
+ server: "SyncServer" = Depends(get_letta_server),
128
+ ):
129
+ """
130
+ Verify the API key and additional parameters for an existing provider.
131
+ """
132
+ try:
133
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
134
+ provider = await server.provider_manager.get_provider_async(provider_id=provider_id, actor=actor)
135
+
136
+ # Create a ProviderCheck from the existing provider
137
+ provider_check = ProviderCheck(
138
+ provider_type=provider.provider_type,
139
+ api_key=provider.api_key,
140
+ base_url=provider.base_url,
141
+ )
142
+
143
+ await server.provider_manager.check_provider_api_key(provider_check=provider_check)
144
+ return JSONResponse(
145
+ status_code=status.HTTP_200_OK, content={"message": f"Valid api key for provider_type={provider.provider_type.value}"}
146
+ )
147
+ except LLMAuthenticationError as e:
148
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"{e.message}")
149
+ except NoResultFound:
150
+ raise HTTPException(status_code=404, detail=f"Provider provider_id={provider_id} not found for user_id={actor.id}.")
151
+ except HTTPException:
152
+ raise
153
+ except Exception as e:
154
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"{e}")
155
+
156
+
123
157
  @router.delete("/{provider_id}", response_model=None, operation_id="delete_provider")
124
158
  async def delete_provider(
125
159
  provider_id: str,
@@ -286,7 +286,7 @@ async def delete_run(
286
286
  """
287
287
  actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
288
288
  runs_manager = RunManager()
289
- return await runs_manager.delete_run_by_id(run_id=run_id, actor=actor)
289
+ return await runs_manager.delete_run(run_id=run_id, actor=actor)
290
290
 
291
291
 
292
292
  @router.post(
letta/server/server.py CHANGED
@@ -1239,6 +1239,16 @@ class SyncServer(object):
1239
1239
  function_args=tool_args,
1240
1240
  tool=tool,
1241
1241
  )
1242
+ from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
1243
+
1244
+ tool_return_obj = ToolReturnSchema(
1245
+ tool_return=str(tool_execution_result.func_return),
1246
+ status=tool_execution_result.status,
1247
+ tool_call_id="null",
1248
+ stdout=tool_execution_result.stdout,
1249
+ stderr=tool_execution_result.stderr,
1250
+ )
1251
+
1242
1252
  return ToolReturnMessage(
1243
1253
  id="null",
1244
1254
  tool_call_id="null",
@@ -1247,10 +1257,21 @@ class SyncServer(object):
1247
1257
  tool_return=str(tool_execution_result.func_return),
1248
1258
  stdout=tool_execution_result.stdout,
1249
1259
  stderr=tool_execution_result.stderr,
1260
+ tool_returns=[tool_return_obj],
1250
1261
  )
1251
1262
 
1252
1263
  except Exception as e:
1253
1264
  func_return = get_friendly_error_msg(function_name=tool.name, exception_name=type(e).__name__, exception_message=str(e))
1265
+ from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
1266
+
1267
+ tool_return_obj = ToolReturnSchema(
1268
+ tool_return=func_return,
1269
+ status="error",
1270
+ tool_call_id="null",
1271
+ stdout=[],
1272
+ stderr=[traceback.format_exc()],
1273
+ )
1274
+
1254
1275
  return ToolReturnMessage(
1255
1276
  id="null",
1256
1277
  tool_call_id="null",
@@ -1259,6 +1280,7 @@ class SyncServer(object):
1259
1280
  tool_return=func_return,
1260
1281
  stdout=[],
1261
1282
  stderr=[traceback.format_exc()],
1283
+ tool_returns=[tool_return_obj],
1262
1284
  )
1263
1285
 
1264
1286
  # MCP wrappers
letta/settings.py CHANGED
@@ -329,6 +329,9 @@ class Settings(BaseSettings):
329
329
  file_processing_timeout_minutes: int = 30
330
330
  file_processing_timeout_error_message: str = "File processing timed out after {} minutes. Please try again."
331
331
 
332
+ # enabling letta_agent_v1 architecture
333
+ use_letta_v1_agent: bool = False
334
+
332
335
  @property
333
336
  def letta_pg_uri(self) -> str:
334
337
  if self.pg_uri:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: letta-nightly
3
- Version: 0.12.0.dev20251009104148
3
+ Version: 0.12.1.dev20251009224219
4
4
  Summary: Create LLM agents with long-term memory and custom tools
5
5
  Author-email: Letta Team <contact@letta.com>
6
6
  License: Apache License