letta-nightly 0.11.7.dev20250916104104__py3-none-any.whl → 0.11.7.dev20250918104055__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/__init__.py +10 -2
- letta/adapters/letta_llm_request_adapter.py +0 -1
- letta/adapters/letta_llm_stream_adapter.py +0 -1
- letta/agent.py +4 -4
- letta/agents/agent_loop.py +2 -1
- letta/agents/base_agent.py +1 -1
- letta/agents/letta_agent.py +1 -4
- letta/agents/letta_agent_v2.py +5 -4
- letta/agents/temporal/activities/__init__.py +4 -0
- letta/agents/temporal/activities/example_activity.py +7 -0
- letta/agents/temporal/activities/prepare_messages.py +10 -0
- letta/agents/temporal/temporal_agent_workflow.py +56 -0
- letta/agents/temporal/types.py +25 -0
- letta/agents/voice_agent.py +3 -3
- letta/helpers/converters.py +8 -2
- letta/helpers/crypto_utils.py +144 -0
- letta/llm_api/llm_api_tools.py +0 -1
- letta/llm_api/llm_client_base.py +0 -2
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +9 -4
- letta/orm/job.py +3 -1
- letta/orm/mcp_oauth.py +6 -0
- letta/orm/mcp_server.py +7 -1
- letta/orm/sqlalchemy_base.py +2 -1
- letta/prompts/prompt_generator.py +4 -4
- letta/schemas/agent.py +14 -200
- letta/schemas/enums.py +15 -0
- letta/schemas/job.py +10 -0
- letta/schemas/mcp.py +146 -6
- letta/schemas/memory.py +216 -103
- letta/schemas/provider_trace.py +0 -2
- letta/schemas/run.py +2 -0
- letta/schemas/secret.py +378 -0
- letta/schemas/step.py +5 -1
- letta/schemas/tool_rule.py +34 -44
- letta/serialize_schemas/marshmallow_agent.py +4 -0
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +9 -4
- letta/server/rest_api/routers/v1/archives.py +113 -0
- letta/server/rest_api/routers/v1/jobs.py +7 -2
- letta/server/rest_api/routers/v1/runs.py +9 -1
- letta/server/rest_api/routers/v1/steps.py +29 -0
- letta/server/rest_api/routers/v1/tools.py +7 -26
- letta/server/server.py +2 -2
- letta/services/agent_manager.py +21 -15
- letta/services/agent_serialization_manager.py +11 -3
- letta/services/archive_manager.py +73 -0
- letta/services/helpers/agent_manager_helper.py +10 -5
- letta/services/job_manager.py +18 -2
- letta/services/mcp_manager.py +198 -82
- letta/services/step_manager.py +26 -0
- letta/services/summarizer/summarizer.py +25 -3
- letta/services/telemetry_manager.py +2 -0
- letta/services/tool_executor/composio_tool_executor.py +1 -1
- letta/services/tool_executor/sandbox_tool_executor.py +2 -2
- letta/services/tool_sandbox/base.py +135 -9
- letta/settings.py +2 -2
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/METADATA +6 -3
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/RECORD +62 -55
- letta/templates/template_helper.py +0 -53
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/licenses/LICENSE +0 -0
letta/schemas/secret.py
ADDED
@@ -0,0 +1,378 @@
|
|
1
|
+
import json
|
2
|
+
from typing import Any, Dict, Optional
|
3
|
+
|
4
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
5
|
+
|
6
|
+
from letta.helpers.crypto_utils import CryptoUtils
|
7
|
+
from letta.log import get_logger
|
8
|
+
|
9
|
+
logger = get_logger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
class Secret(BaseModel):
|
13
|
+
"""
|
14
|
+
A wrapper class for encrypted credentials that keeps values encrypted in memory.
|
15
|
+
|
16
|
+
This class ensures that sensitive data remains encrypted as much as possible
|
17
|
+
while passing through the codebase, only decrypting when absolutely necessary.
|
18
|
+
|
19
|
+
TODO: Once we deprecate plaintext columns in the database:
|
20
|
+
- Remove the dual-write logic in to_dict()
|
21
|
+
- Remove the from_db() method's plaintext_value parameter
|
22
|
+
- Remove the _was_encrypted flag (no longer needed for migration)
|
23
|
+
- Simplify get_plaintext() to only handle encrypted values
|
24
|
+
"""
|
25
|
+
|
26
|
+
# Store the encrypted value
|
27
|
+
_encrypted_value: Optional[str] = PrivateAttr(default=None)
|
28
|
+
# Cache the decrypted value to avoid repeated decryption
|
29
|
+
_plaintext_cache: Optional[str] = PrivateAttr(default=None)
|
30
|
+
# Flag to indicate if the value was originally encrypted
|
31
|
+
_was_encrypted: bool = PrivateAttr(default=False)
|
32
|
+
|
33
|
+
model_config = ConfigDict(frozen=True)
|
34
|
+
|
35
|
+
@classmethod
|
36
|
+
def from_plaintext(cls, value: Optional[str]) -> "Secret":
|
37
|
+
"""
|
38
|
+
Create a Secret from a plaintext value, encrypting it if possible.
|
39
|
+
|
40
|
+
Args:
|
41
|
+
value: The plaintext value to encrypt
|
42
|
+
|
43
|
+
Returns:
|
44
|
+
A Secret instance with the encrypted value, or plaintext if encryption unavailable
|
45
|
+
"""
|
46
|
+
if value is None:
|
47
|
+
instance = cls()
|
48
|
+
instance._encrypted_value = None
|
49
|
+
instance._was_encrypted = False
|
50
|
+
return instance
|
51
|
+
|
52
|
+
# Try to encrypt, but fall back to plaintext if no encryption key
|
53
|
+
try:
|
54
|
+
encrypted = CryptoUtils.encrypt(value)
|
55
|
+
instance = cls()
|
56
|
+
instance._encrypted_value = encrypted
|
57
|
+
instance._was_encrypted = False
|
58
|
+
return instance
|
59
|
+
except ValueError as e:
|
60
|
+
# No encryption key available, store as plaintext
|
61
|
+
if "No encryption key configured" in str(e):
|
62
|
+
logger.warning(
|
63
|
+
"No encryption key configured. Storing Secret value as plaintext. "
|
64
|
+
"Set LETTA_ENCRYPTION_KEY environment variable to enable encryption."
|
65
|
+
)
|
66
|
+
instance = cls()
|
67
|
+
instance._encrypted_value = value # Store plaintext
|
68
|
+
instance._plaintext_cache = value # Cache it
|
69
|
+
instance._was_encrypted = False
|
70
|
+
return instance
|
71
|
+
raise # Re-raise if it's a different error
|
72
|
+
|
73
|
+
@classmethod
|
74
|
+
def from_encrypted(cls, encrypted_value: Optional[str]) -> "Secret":
|
75
|
+
"""
|
76
|
+
Create a Secret from an already encrypted value.
|
77
|
+
|
78
|
+
Args:
|
79
|
+
encrypted_value: The encrypted value
|
80
|
+
|
81
|
+
Returns:
|
82
|
+
A Secret instance
|
83
|
+
"""
|
84
|
+
instance = cls()
|
85
|
+
instance._encrypted_value = encrypted_value
|
86
|
+
instance._was_encrypted = True
|
87
|
+
return instance
|
88
|
+
|
89
|
+
@classmethod
|
90
|
+
def from_db(cls, encrypted_value: Optional[str], plaintext_value: Optional[str]) -> "Secret":
|
91
|
+
"""
|
92
|
+
Create a Secret from database values during migration phase.
|
93
|
+
|
94
|
+
Prefers encrypted value if available, falls back to plaintext.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
encrypted_value: The encrypted value from the database
|
98
|
+
plaintext_value: The plaintext value from the database
|
99
|
+
|
100
|
+
Returns:
|
101
|
+
A Secret instance
|
102
|
+
"""
|
103
|
+
if encrypted_value is not None:
|
104
|
+
return cls.from_encrypted(encrypted_value)
|
105
|
+
elif plaintext_value is not None:
|
106
|
+
return cls.from_plaintext(plaintext_value)
|
107
|
+
else:
|
108
|
+
return cls.from_plaintext(None)
|
109
|
+
|
110
|
+
def get_encrypted(self) -> Optional[str]:
|
111
|
+
"""
|
112
|
+
Get the encrypted value.
|
113
|
+
|
114
|
+
Returns:
|
115
|
+
The encrypted value, or None if the secret is empty
|
116
|
+
"""
|
117
|
+
return self._encrypted_value
|
118
|
+
|
119
|
+
def get_plaintext(self) -> Optional[str]:
|
120
|
+
"""
|
121
|
+
Get the decrypted plaintext value.
|
122
|
+
|
123
|
+
This should only be called when the plaintext is actually needed,
|
124
|
+
such as when making an external API call.
|
125
|
+
|
126
|
+
Returns:
|
127
|
+
The decrypted plaintext value
|
128
|
+
"""
|
129
|
+
if self._encrypted_value is None:
|
130
|
+
return None
|
131
|
+
|
132
|
+
# Use cached value if available, but only if it looks like plaintext
|
133
|
+
# or we're confident we can decrypt it
|
134
|
+
if self._plaintext_cache is not None:
|
135
|
+
# If we have a cache but the stored value looks encrypted and we have no key,
|
136
|
+
# we should not use the cache
|
137
|
+
if CryptoUtils.is_encrypted(self._encrypted_value) and not CryptoUtils.is_encryption_available():
|
138
|
+
self._plaintext_cache = None # Clear invalid cache
|
139
|
+
else:
|
140
|
+
return self._plaintext_cache
|
141
|
+
|
142
|
+
# Decrypt and cache
|
143
|
+
try:
|
144
|
+
plaintext = CryptoUtils.decrypt(self._encrypted_value)
|
145
|
+
# Cache the decrypted value (PrivateAttr fields can be mutated even with frozen=True)
|
146
|
+
self._plaintext_cache = plaintext
|
147
|
+
return plaintext
|
148
|
+
except ValueError as e:
|
149
|
+
error_msg = str(e)
|
150
|
+
|
151
|
+
# Handle missing encryption key
|
152
|
+
if "No encryption key configured" in error_msg:
|
153
|
+
# Check if the value looks encrypted
|
154
|
+
if CryptoUtils.is_encrypted(self._encrypted_value):
|
155
|
+
# Value was encrypted, but now we have no key - can't decrypt
|
156
|
+
logger.warning(
|
157
|
+
"Cannot decrypt Secret value - no encryption key configured. "
|
158
|
+
"The value was encrypted and requires the original key to decrypt."
|
159
|
+
)
|
160
|
+
# Return None to indicate we can't get the plaintext
|
161
|
+
return None
|
162
|
+
else:
|
163
|
+
# Value is plaintext (stored when no key was available)
|
164
|
+
logger.debug("Secret value is plaintext (stored without encryption)")
|
165
|
+
self._plaintext_cache = self._encrypted_value
|
166
|
+
return self._encrypted_value
|
167
|
+
|
168
|
+
# Handle decryption failure (might be plaintext stored as such)
|
169
|
+
elif "Failed to decrypt data" in error_msg:
|
170
|
+
# Check if it might be plaintext
|
171
|
+
if not CryptoUtils.is_encrypted(self._encrypted_value):
|
172
|
+
# It's plaintext that was stored when no key was available
|
173
|
+
logger.debug("Secret value appears to be plaintext (stored without encryption)")
|
174
|
+
self._plaintext_cache = self._encrypted_value
|
175
|
+
return self._encrypted_value
|
176
|
+
# Otherwise, it's corrupted or wrong key
|
177
|
+
logger.error("Failed to decrypt Secret value - data may be corrupted or wrong key")
|
178
|
+
raise
|
179
|
+
|
180
|
+
# 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
|
185
|
+
return None
|
186
|
+
|
187
|
+
# Re-raise for other errors
|
188
|
+
raise
|
189
|
+
|
190
|
+
def is_empty(self) -> bool:
|
191
|
+
"""Check if the secret is empty/None."""
|
192
|
+
return self._encrypted_value is None
|
193
|
+
|
194
|
+
def __str__(self) -> str:
|
195
|
+
"""String representation that doesn't expose the actual value."""
|
196
|
+
if self.is_empty():
|
197
|
+
return "<Secret: empty>"
|
198
|
+
return "<Secret: ****>"
|
199
|
+
|
200
|
+
def __repr__(self) -> str:
|
201
|
+
"""Representation that doesn't expose the actual value."""
|
202
|
+
return self.__str__()
|
203
|
+
|
204
|
+
def to_dict(self) -> Dict[str, Any]:
|
205
|
+
"""
|
206
|
+
Convert to dictionary for database storage.
|
207
|
+
|
208
|
+
Returns both encrypted and plaintext values for dual-write during migration.
|
209
|
+
"""
|
210
|
+
return {"encrypted": self.get_encrypted(), "plaintext": self.get_plaintext() if not self._was_encrypted else None}
|
211
|
+
|
212
|
+
def __eq__(self, other: Any) -> bool:
|
213
|
+
"""
|
214
|
+
Compare two secrets by their plaintext values.
|
215
|
+
|
216
|
+
Note: This decrypts both values, so use sparingly.
|
217
|
+
"""
|
218
|
+
if not isinstance(other, Secret):
|
219
|
+
return False
|
220
|
+
return self.get_plaintext() == other.get_plaintext()
|
221
|
+
|
222
|
+
|
223
|
+
class SecretDict(BaseModel):
|
224
|
+
"""
|
225
|
+
A wrapper for dictionaries containing sensitive key-value pairs.
|
226
|
+
|
227
|
+
Used for custom headers and other key-value configurations.
|
228
|
+
|
229
|
+
TODO: Once we deprecate plaintext columns in the database:
|
230
|
+
- Remove the dual-write logic in to_dict()
|
231
|
+
- Remove the from_db() method's plaintext_value parameter
|
232
|
+
- Remove the _was_encrypted flag (no longer needed for migration)
|
233
|
+
- Simplify get_plaintext() to only handle encrypted JSON values
|
234
|
+
"""
|
235
|
+
|
236
|
+
_encrypted_value: Optional[str] = PrivateAttr(default=None)
|
237
|
+
_plaintext_cache: Optional[Dict[str, str]] = PrivateAttr(default=None)
|
238
|
+
_was_encrypted: bool = PrivateAttr(default=False)
|
239
|
+
|
240
|
+
model_config = ConfigDict(frozen=True)
|
241
|
+
|
242
|
+
@classmethod
|
243
|
+
def from_plaintext(cls, value: Optional[Dict[str, str]]) -> "SecretDict":
|
244
|
+
"""Create a SecretDict from a plaintext dictionary."""
|
245
|
+
if value is None:
|
246
|
+
instance = cls()
|
247
|
+
instance._encrypted_value = None
|
248
|
+
instance._was_encrypted = False
|
249
|
+
return instance
|
250
|
+
|
251
|
+
# Serialize to JSON then try to encrypt
|
252
|
+
json_str = json.dumps(value)
|
253
|
+
try:
|
254
|
+
encrypted = CryptoUtils.encrypt(json_str)
|
255
|
+
instance = cls()
|
256
|
+
instance._encrypted_value = encrypted
|
257
|
+
instance._was_encrypted = False
|
258
|
+
return instance
|
259
|
+
except ValueError as e:
|
260
|
+
# No encryption key available, store as plaintext JSON
|
261
|
+
if "No encryption key configured" in str(e):
|
262
|
+
logger.warning(
|
263
|
+
"No encryption key configured. Storing SecretDict value as plaintext JSON. "
|
264
|
+
"Set LETTA_ENCRYPTION_KEY environment variable to enable encryption."
|
265
|
+
)
|
266
|
+
instance = cls()
|
267
|
+
instance._encrypted_value = json_str # Store JSON string
|
268
|
+
instance._plaintext_cache = value # Cache the dict
|
269
|
+
instance._was_encrypted = False
|
270
|
+
return instance
|
271
|
+
raise # Re-raise if it's a different error
|
272
|
+
|
273
|
+
@classmethod
|
274
|
+
def from_encrypted(cls, encrypted_value: Optional[str]) -> "SecretDict":
|
275
|
+
"""Create a SecretDict from an encrypted value."""
|
276
|
+
instance = cls()
|
277
|
+
instance._encrypted_value = encrypted_value
|
278
|
+
instance._was_encrypted = True
|
279
|
+
return instance
|
280
|
+
|
281
|
+
@classmethod
|
282
|
+
def from_db(cls, encrypted_value: Optional[str], plaintext_value: Optional[Dict[str, str]]) -> "SecretDict":
|
283
|
+
"""Create a SecretDict from database values during migration phase."""
|
284
|
+
if encrypted_value is not None:
|
285
|
+
return cls.from_encrypted(encrypted_value)
|
286
|
+
elif plaintext_value is not None:
|
287
|
+
return cls.from_plaintext(plaintext_value)
|
288
|
+
else:
|
289
|
+
return cls.from_plaintext(None)
|
290
|
+
|
291
|
+
def get_encrypted(self) -> Optional[str]:
|
292
|
+
"""Get the encrypted value."""
|
293
|
+
return self._encrypted_value
|
294
|
+
|
295
|
+
def get_plaintext(self) -> Optional[Dict[str, str]]:
|
296
|
+
"""Get the decrypted dictionary."""
|
297
|
+
if self._encrypted_value is None:
|
298
|
+
return None
|
299
|
+
|
300
|
+
# Use cached value if available, but only if it looks like plaintext
|
301
|
+
# or we're confident we can decrypt it
|
302
|
+
if self._plaintext_cache is not None:
|
303
|
+
# If we have a cache but the stored value looks encrypted and we have no key,
|
304
|
+
# we should not use the cache
|
305
|
+
if CryptoUtils.is_encrypted(self._encrypted_value) and not CryptoUtils.is_encryption_available():
|
306
|
+
self._plaintext_cache = None # Clear invalid cache
|
307
|
+
else:
|
308
|
+
return self._plaintext_cache
|
309
|
+
|
310
|
+
try:
|
311
|
+
decrypted_json = CryptoUtils.decrypt(self._encrypted_value)
|
312
|
+
plaintext_dict = json.loads(decrypted_json)
|
313
|
+
# Cache the decrypted value (PrivateAttr fields can be mutated even with frozen=True)
|
314
|
+
self._plaintext_cache = plaintext_dict
|
315
|
+
return plaintext_dict
|
316
|
+
except ValueError as e:
|
317
|
+
error_msg = str(e)
|
318
|
+
|
319
|
+
# Handle missing encryption key
|
320
|
+
if "No encryption key configured" in error_msg:
|
321
|
+
# Check if the value looks encrypted
|
322
|
+
if CryptoUtils.is_encrypted(self._encrypted_value):
|
323
|
+
# Value was encrypted, but now we have no key - can't decrypt
|
324
|
+
logger.warning(
|
325
|
+
"Cannot decrypt SecretDict value - no encryption key configured. "
|
326
|
+
"The value was encrypted and requires the original key to decrypt."
|
327
|
+
)
|
328
|
+
# Return None to indicate we can't get the plaintext
|
329
|
+
return None
|
330
|
+
else:
|
331
|
+
# Value is plaintext JSON (stored when no key was available)
|
332
|
+
logger.debug("SecretDict value is plaintext JSON (stored without encryption)")
|
333
|
+
try:
|
334
|
+
plaintext_dict = json.loads(self._encrypted_value)
|
335
|
+
self._plaintext_cache = plaintext_dict
|
336
|
+
return plaintext_dict
|
337
|
+
except json.JSONDecodeError:
|
338
|
+
logger.error("Failed to parse SecretDict plaintext as JSON")
|
339
|
+
return None
|
340
|
+
|
341
|
+
# Handle decryption failure (might be plaintext JSON)
|
342
|
+
elif "Failed to decrypt data" in error_msg:
|
343
|
+
# Check if it might be plaintext JSON
|
344
|
+
if not CryptoUtils.is_encrypted(self._encrypted_value):
|
345
|
+
# It's plaintext JSON that was stored when no key was available
|
346
|
+
logger.debug("SecretDict value appears to be plaintext JSON (stored without encryption)")
|
347
|
+
try:
|
348
|
+
plaintext_dict = json.loads(self._encrypted_value)
|
349
|
+
self._plaintext_cache = plaintext_dict
|
350
|
+
return plaintext_dict
|
351
|
+
except json.JSONDecodeError:
|
352
|
+
logger.error("Failed to parse SecretDict plaintext as JSON")
|
353
|
+
return None
|
354
|
+
# Otherwise, it's corrupted or wrong key
|
355
|
+
logger.error("Failed to decrypt SecretDict value - data may be corrupted or wrong key")
|
356
|
+
raise
|
357
|
+
|
358
|
+
# Migration case: handle legacy plaintext
|
359
|
+
elif not self._was_encrypted:
|
360
|
+
if self._encrypted_value:
|
361
|
+
try:
|
362
|
+
plaintext_dict = json.loads(self._encrypted_value)
|
363
|
+
self._plaintext_cache = plaintext_dict
|
364
|
+
return plaintext_dict
|
365
|
+
except json.JSONDecodeError:
|
366
|
+
pass
|
367
|
+
return None
|
368
|
+
|
369
|
+
# Re-raise for other errors
|
370
|
+
raise
|
371
|
+
|
372
|
+
def is_empty(self) -> bool:
|
373
|
+
"""Check if the secret dict is empty/None."""
|
374
|
+
return self._encrypted_value is None
|
375
|
+
|
376
|
+
def to_dict(self) -> Dict[str, Any]:
|
377
|
+
"""Convert to dictionary for database storage."""
|
378
|
+
return {"encrypted": self.get_encrypted(), "plaintext": self.get_plaintext() if not self._was_encrypted else None}
|
letta/schemas/step.py
CHANGED
@@ -35,7 +35,11 @@ class Step(StepBase):
|
|
35
35
|
tags: List[str] = Field([], description="Metadata tags.")
|
36
36
|
tid: Optional[str] = Field(None, description="The unique identifier of the transaction that processed this step.")
|
37
37
|
trace_id: Optional[str] = Field(None, description="The trace id of the agent step.")
|
38
|
-
messages: List[Message] = Field(
|
38
|
+
messages: List[Message] = Field(
|
39
|
+
[],
|
40
|
+
description="The messages generated during this step. Deprecated: use `GET /v1/steps/{step_id}/messages` endpoint instead",
|
41
|
+
deprecated=True,
|
42
|
+
)
|
39
43
|
feedback: Optional[Literal["positive", "negative"]] = Field(
|
40
44
|
None, description="The feedback for this step. Must be either 'positive' or 'negative'."
|
41
45
|
)
|
letta/schemas/tool_rule.py
CHANGED
@@ -2,7 +2,6 @@ import json
|
|
2
2
|
import logging
|
3
3
|
from typing import Annotated, Any, Dict, List, Literal, Optional, Set, Union
|
4
4
|
|
5
|
-
from jinja2 import Template
|
6
5
|
from pydantic import Field, field_validator
|
7
6
|
|
8
7
|
from letta.schemas.enums import ToolRuleType
|
@@ -17,7 +16,7 @@ class BaseToolRule(LettaBase):
|
|
17
16
|
type: ToolRuleType = Field(..., description="The type of the message.")
|
18
17
|
prompt_template: Optional[str] = Field(
|
19
18
|
None,
|
20
|
-
description="Optional
|
19
|
+
description="Optional template string (ignored). Rendering uses fast built-in formatting for performance.",
|
21
20
|
)
|
22
21
|
|
23
22
|
def __hash__(self):
|
@@ -34,22 +33,8 @@ class BaseToolRule(LettaBase):
|
|
34
33
|
raise NotImplementedError
|
35
34
|
|
36
35
|
def render_prompt(self) -> str | None:
|
37
|
-
"""
|
38
|
-
|
39
|
-
return None
|
40
|
-
|
41
|
-
try:
|
42
|
-
template = Template(self.prompt_template)
|
43
|
-
return template.render(**self.model_dump())
|
44
|
-
except Exception as e:
|
45
|
-
logger.warning(
|
46
|
-
"Failed to render prompt template for tool rule '%s' (type: %s). Template: '%s'. Error: %s",
|
47
|
-
self.tool_name,
|
48
|
-
self.type,
|
49
|
-
self.prompt_template,
|
50
|
-
e,
|
51
|
-
)
|
52
|
-
return None
|
36
|
+
"""Default implementation returns None. Subclasses provide optimized strings."""
|
37
|
+
return None
|
53
38
|
|
54
39
|
|
55
40
|
class ChildToolRule(BaseToolRule):
|
@@ -60,8 +45,8 @@ class ChildToolRule(BaseToolRule):
|
|
60
45
|
type: Literal[ToolRuleType.constrain_child_tools] = ToolRuleType.constrain_child_tools
|
61
46
|
children: List[str] = Field(..., description="The children tools that can be invoked.")
|
62
47
|
prompt_template: Optional[str] = Field(
|
63
|
-
default=
|
64
|
-
description="Optional
|
48
|
+
default=None,
|
49
|
+
description="Optional template string (ignored).",
|
65
50
|
)
|
66
51
|
|
67
52
|
def __hash__(self):
|
@@ -78,6 +63,10 @@ class ChildToolRule(BaseToolRule):
|
|
78
63
|
last_tool = tool_call_history[-1] if tool_call_history else None
|
79
64
|
return set(self.children) if last_tool == self.tool_name else available_tools
|
80
65
|
|
66
|
+
def render_prompt(self) -> str | None:
|
67
|
+
children_str = ", ".join(self.children)
|
68
|
+
return f"<tool_rule>\nAfter using {self.tool_name}, you must use one of these tools: {children_str}\n</tool_rule>"
|
69
|
+
|
81
70
|
|
82
71
|
class ParentToolRule(BaseToolRule):
|
83
72
|
"""
|
@@ -86,10 +75,7 @@ class ParentToolRule(BaseToolRule):
|
|
86
75
|
|
87
76
|
type: Literal[ToolRuleType.parent_last_tool] = ToolRuleType.parent_last_tool
|
88
77
|
children: List[str] = Field(..., description="The children tools that can be invoked.")
|
89
|
-
prompt_template: Optional[str] = Field(
|
90
|
-
default="<tool_rule>\n{{ children | join(', ') }} can only be used after {{ tool_name }}\n</tool_rule>",
|
91
|
-
description="Optional Jinja2 template for generating agent prompt about this tool rule.",
|
92
|
-
)
|
78
|
+
prompt_template: Optional[str] = Field(default=None, description="Optional template string (ignored).")
|
93
79
|
|
94
80
|
def __hash__(self):
|
95
81
|
"""Hash including children list (sorted for consistency)."""
|
@@ -105,6 +91,10 @@ class ParentToolRule(BaseToolRule):
|
|
105
91
|
last_tool = tool_call_history[-1] if tool_call_history else None
|
106
92
|
return set(self.children) if last_tool == self.tool_name else available_tools - set(self.children)
|
107
93
|
|
94
|
+
def render_prompt(self) -> str | None:
|
95
|
+
children_str = ", ".join(self.children)
|
96
|
+
return f"<tool_rule>\n{children_str} can only be used after {self.tool_name}\n</tool_rule>"
|
97
|
+
|
108
98
|
|
109
99
|
class ConditionalToolRule(BaseToolRule):
|
110
100
|
"""
|
@@ -115,10 +105,7 @@ class ConditionalToolRule(BaseToolRule):
|
|
115
105
|
default_child: Optional[str] = Field(None, description="The default child tool to be called. If None, any tool can be called.")
|
116
106
|
child_output_mapping: Dict[Any, str] = Field(..., description="The output case to check for mapping")
|
117
107
|
require_output_mapping: bool = Field(default=False, description="Whether to throw an error when output doesn't match any case")
|
118
|
-
prompt_template: Optional[str] = Field(
|
119
|
-
default="<tool_rule>\n{{ tool_name }} will determine which tool to use next based on its output\n</tool_rule>",
|
120
|
-
description="Optional Jinja2 template for generating agent prompt about this tool rule.",
|
121
|
-
)
|
108
|
+
prompt_template: Optional[str] = Field(default=None, description="Optional template string (ignored).")
|
122
109
|
|
123
110
|
def __hash__(self):
|
124
111
|
"""Hash including all configuration fields."""
|
@@ -165,6 +152,9 @@ class ConditionalToolRule(BaseToolRule):
|
|
165
152
|
|
166
153
|
return {self.default_child} if self.default_child else available_tools
|
167
154
|
|
155
|
+
def render_prompt(self) -> str | None:
|
156
|
+
return f"<tool_rule>\n{self.tool_name} will determine which tool to use next based on its output\n</tool_rule>"
|
157
|
+
|
168
158
|
@field_validator("child_output_mapping")
|
169
159
|
@classmethod
|
170
160
|
def validate_child_output_mapping(cls, v):
|
@@ -205,10 +195,10 @@ class TerminalToolRule(BaseToolRule):
|
|
205
195
|
"""
|
206
196
|
|
207
197
|
type: Literal[ToolRuleType.exit_loop] = ToolRuleType.exit_loop
|
208
|
-
prompt_template: Optional[str] = Field(
|
209
|
-
|
210
|
-
|
211
|
-
|
198
|
+
prompt_template: Optional[str] = Field(default=None, description="Optional template string (ignored).")
|
199
|
+
|
200
|
+
def render_prompt(self) -> str | None:
|
201
|
+
return f"<tool_rule>\n{self.tool_name} ends your response (yields control) when called\n</tool_rule>"
|
212
202
|
|
213
203
|
|
214
204
|
class ContinueToolRule(BaseToolRule):
|
@@ -217,10 +207,10 @@ class ContinueToolRule(BaseToolRule):
|
|
217
207
|
"""
|
218
208
|
|
219
209
|
type: Literal[ToolRuleType.continue_loop] = ToolRuleType.continue_loop
|
220
|
-
prompt_template: Optional[str] = Field(
|
221
|
-
|
222
|
-
|
223
|
-
|
210
|
+
prompt_template: Optional[str] = Field(default=None, description="Optional template string (ignored).")
|
211
|
+
|
212
|
+
def render_prompt(self) -> str | None:
|
213
|
+
return f"<tool_rule>\n{self.tool_name} requires continuing your response when called\n</tool_rule>"
|
224
214
|
|
225
215
|
|
226
216
|
class RequiredBeforeExitToolRule(BaseToolRule):
|
@@ -229,15 +219,15 @@ class RequiredBeforeExitToolRule(BaseToolRule):
|
|
229
219
|
"""
|
230
220
|
|
231
221
|
type: Literal[ToolRuleType.required_before_exit] = ToolRuleType.required_before_exit
|
232
|
-
prompt_template: Optional[str] = Field(
|
233
|
-
default="<tool_rule>{{ tool_name }} must be called before ending the conversation</tool_rule>",
|
234
|
-
description="Optional Jinja2 template for generating agent prompt about this tool rule.",
|
235
|
-
)
|
222
|
+
prompt_template: Optional[str] = Field(default=None, description="Optional template string (ignored).")
|
236
223
|
|
237
224
|
def get_valid_tools(self, tool_call_history: List[str], available_tools: Set[str], last_function_response: Optional[str]) -> Set[str]:
|
238
225
|
"""Returns all available tools - the logic for preventing exit is handled elsewhere."""
|
239
226
|
return available_tools
|
240
227
|
|
228
|
+
def render_prompt(self) -> str | None:
|
229
|
+
return f"<tool_rule>{self.tool_name} must be called before ending the conversation</tool_rule>"
|
230
|
+
|
241
231
|
|
242
232
|
class MaxCountPerStepToolRule(BaseToolRule):
|
243
233
|
"""
|
@@ -246,10 +236,7 @@ class MaxCountPerStepToolRule(BaseToolRule):
|
|
246
236
|
|
247
237
|
type: Literal[ToolRuleType.max_count_per_step] = ToolRuleType.max_count_per_step
|
248
238
|
max_count_limit: int = Field(..., description="The max limit for the total number of times this tool can be invoked in a single step.")
|
249
|
-
prompt_template: Optional[str] = Field(
|
250
|
-
default="<tool_rule>\n{{ tool_name }}: at most {{ max_count_limit }} use(s) per response\n</tool_rule>",
|
251
|
-
description="Optional Jinja2 template for generating agent prompt about this tool rule.",
|
252
|
-
)
|
239
|
+
prompt_template: Optional[str] = Field(default=None, description="Optional template string (ignored).")
|
253
240
|
|
254
241
|
def __hash__(self):
|
255
242
|
"""Hash including max_count_limit."""
|
@@ -271,6 +258,9 @@ class MaxCountPerStepToolRule(BaseToolRule):
|
|
271
258
|
|
272
259
|
return available_tools
|
273
260
|
|
261
|
+
def render_prompt(self) -> str | None:
|
262
|
+
return f"<tool_rule>\n{self.tool_name}: at most {self.max_count_limit} use(s) per response\n</tool_rule>"
|
263
|
+
|
274
264
|
|
275
265
|
class RequiresApprovalToolRule(BaseToolRule):
|
276
266
|
"""
|
@@ -40,6 +40,7 @@ class MarshmallowAgentSchema(BaseSchema):
|
|
40
40
|
core_memory = fields.List(fields.Nested(SerializedBlockSchema))
|
41
41
|
tools = fields.List(fields.Nested(SerializedToolSchema))
|
42
42
|
tool_exec_environment_variables = fields.List(fields.Nested(SerializedAgentEnvironmentVariableSchema))
|
43
|
+
secrets = fields.List(fields.Nested(SerializedAgentEnvironmentVariableSchema))
|
43
44
|
tags = fields.List(fields.Nested(SerializedAgentTagSchema))
|
44
45
|
|
45
46
|
def __init__(self, *args, session: sessionmaker, actor: User, max_steps: Optional[int] = None, **kwargs):
|
@@ -214,6 +215,9 @@ class MarshmallowAgentSchema(BaseSchema):
|
|
214
215
|
for env_var in data.get("tool_exec_environment_variables", []):
|
215
216
|
# need to be re-set at load time
|
216
217
|
env_var["value"] = ""
|
218
|
+
for env_var in data.get("secrets", []):
|
219
|
+
# need to be re-set at load time
|
220
|
+
env_var["value"] = ""
|
217
221
|
return data
|
218
222
|
|
219
223
|
@pre_load
|
@@ -1,4 +1,5 @@
|
|
1
1
|
from letta.server.rest_api.routers.v1.agents import router as agents_router
|
2
|
+
from letta.server.rest_api.routers.v1.archives import router as archives_router
|
2
3
|
from letta.server.rest_api.routers.v1.blocks import router as blocks_router
|
3
4
|
from letta.server.rest_api.routers.v1.embeddings import router as embeddings_router
|
4
5
|
from letta.server.rest_api.routers.v1.folders import router as folders_router
|
@@ -20,6 +21,7 @@ from letta.server.rest_api.routers.v1.tools import router as tools_router
|
|
20
21
|
from letta.server.rest_api.routers.v1.voice import router as voice_router
|
21
22
|
|
22
23
|
ROUTERS = [
|
24
|
+
archives_router,
|
23
25
|
tools_router,
|
24
26
|
sources_router,
|
25
27
|
folders_router,
|
@@ -38,6 +38,7 @@ from letta.schemas.job import JobStatus, JobUpdate, LettaRequestConfig
|
|
38
38
|
from letta.schemas.letta_message import LettaMessageUnion, LettaMessageUpdateUnion, MessageType
|
39
39
|
from letta.schemas.letta_request import LettaAsyncRequest, LettaRequest, LettaStreamingRequest
|
40
40
|
from letta.schemas.letta_response import LettaResponse
|
41
|
+
from letta.schemas.letta_stop_reason import StopReasonType
|
41
42
|
from letta.schemas.memory import (
|
42
43
|
ArchivalMemorySearchResponse,
|
43
44
|
ArchivalMemorySearchResult,
|
@@ -1192,6 +1193,7 @@ async def send_message(
|
|
1192
1193
|
await redis_client.set(f"{REDIS_RUN_ID_PREFIX}:{agent_id}", run.id if run else None)
|
1193
1194
|
|
1194
1195
|
try:
|
1196
|
+
result = None
|
1195
1197
|
if agent_eligible and model_compatible:
|
1196
1198
|
agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
|
1197
1199
|
result = await agent_loop.step(
|
@@ -1229,11 +1231,17 @@ async def send_message(
|
|
1229
1231
|
raise
|
1230
1232
|
finally:
|
1231
1233
|
if settings.track_agent_run:
|
1234
|
+
if result:
|
1235
|
+
stop_reason = result.stop_reason.stop_reason
|
1236
|
+
else:
|
1237
|
+
# NOTE: we could also consider this an error?
|
1238
|
+
stop_reason = None
|
1232
1239
|
await server.job_manager.safe_update_job_status_async(
|
1233
1240
|
job_id=run.id,
|
1234
1241
|
new_status=job_status,
|
1235
1242
|
actor=actor,
|
1236
1243
|
metadata=job_update_metadata,
|
1244
|
+
stop_reason=stop_reason,
|
1237
1245
|
)
|
1238
1246
|
|
1239
1247
|
|
@@ -1440,10 +1448,7 @@ async def send_message_streaming(
|
|
1440
1448
|
finally:
|
1441
1449
|
if settings.track_agent_run:
|
1442
1450
|
await server.job_manager.safe_update_job_status_async(
|
1443
|
-
job_id=run.id,
|
1444
|
-
new_status=job_status,
|
1445
|
-
actor=actor,
|
1446
|
-
metadata=job_update_metadata,
|
1451
|
+
job_id=run.id, new_status=job_status, actor=actor, metadata=job_update_metadata
|
1447
1452
|
)
|
1448
1453
|
|
1449
1454
|
|