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.
Files changed (63) hide show
  1. letta/__init__.py +10 -2
  2. letta/adapters/letta_llm_request_adapter.py +0 -1
  3. letta/adapters/letta_llm_stream_adapter.py +0 -1
  4. letta/agent.py +4 -4
  5. letta/agents/agent_loop.py +2 -1
  6. letta/agents/base_agent.py +1 -1
  7. letta/agents/letta_agent.py +1 -4
  8. letta/agents/letta_agent_v2.py +5 -4
  9. letta/agents/temporal/activities/__init__.py +4 -0
  10. letta/agents/temporal/activities/example_activity.py +7 -0
  11. letta/agents/temporal/activities/prepare_messages.py +10 -0
  12. letta/agents/temporal/temporal_agent_workflow.py +56 -0
  13. letta/agents/temporal/types.py +25 -0
  14. letta/agents/voice_agent.py +3 -3
  15. letta/helpers/converters.py +8 -2
  16. letta/helpers/crypto_utils.py +144 -0
  17. letta/llm_api/llm_api_tools.py +0 -1
  18. letta/llm_api/llm_client_base.py +0 -2
  19. letta/orm/__init__.py +1 -0
  20. letta/orm/agent.py +9 -4
  21. letta/orm/job.py +3 -1
  22. letta/orm/mcp_oauth.py +6 -0
  23. letta/orm/mcp_server.py +7 -1
  24. letta/orm/sqlalchemy_base.py +2 -1
  25. letta/prompts/prompt_generator.py +4 -4
  26. letta/schemas/agent.py +14 -200
  27. letta/schemas/enums.py +15 -0
  28. letta/schemas/job.py +10 -0
  29. letta/schemas/mcp.py +146 -6
  30. letta/schemas/memory.py +216 -103
  31. letta/schemas/provider_trace.py +0 -2
  32. letta/schemas/run.py +2 -0
  33. letta/schemas/secret.py +378 -0
  34. letta/schemas/step.py +5 -1
  35. letta/schemas/tool_rule.py +34 -44
  36. letta/serialize_schemas/marshmallow_agent.py +4 -0
  37. letta/server/rest_api/routers/v1/__init__.py +2 -0
  38. letta/server/rest_api/routers/v1/agents.py +9 -4
  39. letta/server/rest_api/routers/v1/archives.py +113 -0
  40. letta/server/rest_api/routers/v1/jobs.py +7 -2
  41. letta/server/rest_api/routers/v1/runs.py +9 -1
  42. letta/server/rest_api/routers/v1/steps.py +29 -0
  43. letta/server/rest_api/routers/v1/tools.py +7 -26
  44. letta/server/server.py +2 -2
  45. letta/services/agent_manager.py +21 -15
  46. letta/services/agent_serialization_manager.py +11 -3
  47. letta/services/archive_manager.py +73 -0
  48. letta/services/helpers/agent_manager_helper.py +10 -5
  49. letta/services/job_manager.py +18 -2
  50. letta/services/mcp_manager.py +198 -82
  51. letta/services/step_manager.py +26 -0
  52. letta/services/summarizer/summarizer.py +25 -3
  53. letta/services/telemetry_manager.py +2 -0
  54. letta/services/tool_executor/composio_tool_executor.py +1 -1
  55. letta/services/tool_executor/sandbox_tool_executor.py +2 -2
  56. letta/services/tool_sandbox/base.py +135 -9
  57. letta/settings.py +2 -2
  58. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/METADATA +6 -3
  59. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/RECORD +62 -55
  60. letta/templates/template_helper.py +0 -53
  61. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/WHEEL +0 -0
  62. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/entry_points.txt +0 -0
  63. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/licenses/LICENSE +0 -0
@@ -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([], description="The messages generated during this step.")
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
  )
@@ -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 Jinja2 template for generating agent prompt about this tool rule. Template can use variables like 'tool_name' and rule-specific attributes.",
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
- """Render the prompt template with this rule's attributes."""
38
- if not self.prompt_template:
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="<tool_rule>\nAfter using {{ tool_name }}, you must use one of these tools: {{ children | join(', ') }}\n</tool_rule>",
64
- description="Optional Jinja2 template for generating agent prompt about this tool rule.",
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
- default="<tool_rule>\n{{ tool_name }} ends your response (yields control) when called\n</tool_rule>",
210
- description="Optional Jinja2 template for generating agent prompt about this tool rule.",
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
- default="<tool_rule>\n{{ tool_name }} requires continuing your response when called\n</tool_rule>",
222
- description="Optional Jinja2 template for generating agent prompt about this tool rule.",
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