letta-nightly 0.6.48.dev20250407104216__py3-none-any.whl → 0.6.49.dev20250408104230__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 (87) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +47 -12
  3. letta/agents/base_agent.py +7 -4
  4. letta/agents/helpers.py +52 -0
  5. letta/agents/letta_agent.py +105 -42
  6. letta/agents/voice_agent.py +2 -2
  7. letta/constants.py +13 -1
  8. letta/errors.py +10 -3
  9. letta/functions/function_sets/base.py +65 -0
  10. letta/functions/interface.py +2 -2
  11. letta/functions/mcp_client/base_client.py +18 -1
  12. letta/{dynamic_multi_agent.py → groups/dynamic_multi_agent.py} +3 -0
  13. letta/groups/helpers.py +113 -0
  14. letta/{round_robin_multi_agent.py → groups/round_robin_multi_agent.py} +2 -0
  15. letta/groups/sleeptime_multi_agent.py +259 -0
  16. letta/{supervisor_multi_agent.py → groups/supervisor_multi_agent.py} +1 -0
  17. letta/helpers/converters.py +109 -7
  18. letta/helpers/message_helper.py +1 -0
  19. letta/helpers/tool_rule_solver.py +40 -23
  20. letta/interface.py +12 -5
  21. letta/interfaces/anthropic_streaming_interface.py +329 -0
  22. letta/llm_api/anthropic.py +12 -1
  23. letta/llm_api/anthropic_client.py +65 -14
  24. letta/llm_api/azure_openai.py +2 -2
  25. letta/llm_api/google_ai_client.py +13 -2
  26. letta/llm_api/google_constants.py +3 -0
  27. letta/llm_api/google_vertex_client.py +2 -2
  28. letta/llm_api/llm_api_tools.py +1 -1
  29. letta/llm_api/llm_client.py +7 -0
  30. letta/llm_api/llm_client_base.py +2 -7
  31. letta/llm_api/openai.py +7 -1
  32. letta/llm_api/openai_client.py +250 -0
  33. letta/orm/__init__.py +4 -0
  34. letta/orm/agent.py +6 -0
  35. letta/orm/block.py +32 -2
  36. letta/orm/block_history.py +46 -0
  37. letta/orm/custom_columns.py +60 -0
  38. letta/orm/enums.py +7 -0
  39. letta/orm/group.py +6 -0
  40. letta/orm/groups_blocks.py +13 -0
  41. letta/orm/llm_batch_items.py +55 -0
  42. letta/orm/llm_batch_job.py +48 -0
  43. letta/orm/message.py +7 -1
  44. letta/orm/organization.py +2 -0
  45. letta/orm/sqlalchemy_base.py +18 -15
  46. letta/prompts/system/memgpt_sleeptime_chat.txt +52 -0
  47. letta/prompts/system/sleeptime.txt +26 -0
  48. letta/schemas/agent.py +13 -1
  49. letta/schemas/enums.py +17 -2
  50. letta/schemas/group.py +14 -1
  51. letta/schemas/letta_message.py +5 -3
  52. letta/schemas/llm_batch_job.py +53 -0
  53. letta/schemas/llm_config.py +14 -4
  54. letta/schemas/message.py +44 -0
  55. letta/schemas/tool.py +3 -0
  56. letta/schemas/usage.py +1 -0
  57. letta/server/db.py +2 -0
  58. letta/server/rest_api/app.py +1 -1
  59. letta/server/rest_api/chat_completions_interface.py +8 -3
  60. letta/server/rest_api/interface.py +36 -7
  61. letta/server/rest_api/routers/v1/agents.py +53 -39
  62. letta/server/rest_api/routers/v1/runs.py +14 -2
  63. letta/server/rest_api/utils.py +15 -4
  64. letta/server/server.py +120 -71
  65. letta/services/agent_manager.py +70 -6
  66. letta/services/block_manager.py +190 -2
  67. letta/services/group_manager.py +68 -0
  68. letta/services/helpers/agent_manager_helper.py +6 -4
  69. letta/services/llm_batch_manager.py +139 -0
  70. letta/services/message_manager.py +17 -31
  71. letta/services/tool_executor/tool_execution_sandbox.py +1 -3
  72. letta/services/tool_executor/tool_executor.py +9 -20
  73. letta/services/tool_manager.py +14 -3
  74. letta/services/tool_sandbox/__init__.py +0 -0
  75. letta/services/tool_sandbox/base.py +188 -0
  76. letta/services/tool_sandbox/e2b_sandbox.py +116 -0
  77. letta/services/tool_sandbox/local_sandbox.py +221 -0
  78. letta/sleeptime_agent.py +61 -0
  79. letta/streaming_interface.py +20 -10
  80. letta/utils.py +4 -0
  81. {letta_nightly-0.6.48.dev20250407104216.dist-info → letta_nightly-0.6.49.dev20250408104230.dist-info}/METADATA +2 -2
  82. {letta_nightly-0.6.48.dev20250407104216.dist-info → letta_nightly-0.6.49.dev20250408104230.dist-info}/RECORD +85 -69
  83. letta/offline_memory_agent.py +0 -173
  84. letta/services/tool_executor/async_tool_execution_sandbox.py +0 -397
  85. {letta_nightly-0.6.48.dev20250407104216.dist-info → letta_nightly-0.6.49.dev20250408104230.dist-info}/LICENSE +0 -0
  86. {letta_nightly-0.6.48.dev20250407104216.dist-info → letta_nightly-0.6.49.dev20250408104230.dist-info}/WHEEL +0 -0
  87. {letta_nightly-0.6.48.dev20250407104216.dist-info → letta_nightly-0.6.49.dev20250408104230.dist-info}/entry_points.txt +0 -0
@@ -1,10 +1,13 @@
1
1
  import os
2
2
  from typing import List, Optional
3
3
 
4
+ from sqlalchemy.orm import Session
5
+
4
6
  from letta.orm.block import Block as BlockModel
7
+ from letta.orm.block_history import BlockHistory
8
+ from letta.orm.enums import ActorType
5
9
  from letta.orm.errors import NoResultFound
6
10
  from letta.schemas.agent import AgentState as PydanticAgentState
7
- from letta.schemas.block import Block
8
11
  from letta.schemas.block import Block as PydanticBlock
9
12
  from letta.schemas.block import BlockUpdate, Human, Persona
10
13
  from letta.schemas.user import User as PydanticUser
@@ -21,7 +24,7 @@ class BlockManager:
21
24
  self.session_maker = db_context
22
25
 
23
26
  @enforce_types
24
- def create_or_update_block(self, block: Block, actor: PydanticUser) -> PydanticBlock:
27
+ def create_or_update_block(self, block: PydanticBlock, actor: PydanticUser) -> PydanticBlock:
25
28
  """Create a new block based on the Block schema."""
26
29
  db_block = self.get_block_by_id(block.id, actor)
27
30
  if db_block:
@@ -140,3 +143,188 @@ class BlockManager:
140
143
  agents_pydantic = [agent.to_pydantic() for agent in agents_orm]
141
144
 
142
145
  return agents_pydantic
146
+
147
+ # Block History Functions
148
+
149
+ @enforce_types
150
+ def checkpoint_block(
151
+ self,
152
+ block_id: str,
153
+ actor: PydanticUser,
154
+ agent_id: Optional[str] = None,
155
+ use_preloaded_block: Optional[BlockModel] = None, # For concurrency tests
156
+ ) -> PydanticBlock:
157
+ """
158
+ Create a new checkpoint for the given Block by copying its
159
+ current state into BlockHistory, using SQLAlchemy's built-in
160
+ version_id_col for concurrency checks.
161
+
162
+ - If the block was undone to an earlier checkpoint, we remove
163
+ any "future" checkpoints beyond the current state to keep a
164
+ strictly linear history.
165
+ - A single commit at the end ensures atomicity.
166
+ """
167
+ with self.session_maker() as session:
168
+ # 1) Load the Block
169
+ if use_preloaded_block is not None:
170
+ block = session.merge(use_preloaded_block)
171
+ else:
172
+ block = BlockModel.read(db_session=session, identifier=block_id, actor=actor)
173
+
174
+ # 2) Identify the block's current checkpoint (if any)
175
+ current_entry = None
176
+ if block.current_history_entry_id:
177
+ current_entry = session.get(BlockHistory, block.current_history_entry_id)
178
+
179
+ # The current sequence, or 0 if no checkpoints exist
180
+ current_seq = current_entry.sequence_number if current_entry else 0
181
+
182
+ # 3) Truncate any future checkpoints
183
+ # If we are at seq=2, but there's a seq=3 or higher from a prior "redo chain",
184
+ # remove those, so we maintain a strictly linear undo/redo stack.
185
+ session.query(BlockHistory).filter(BlockHistory.block_id == block.id, BlockHistory.sequence_number > current_seq).delete()
186
+
187
+ # 4) Determine the next sequence number
188
+ next_seq = current_seq + 1
189
+
190
+ # 5) Create a new BlockHistory row reflecting the block's current state
191
+ history_entry = BlockHistory(
192
+ organization_id=actor.organization_id,
193
+ block_id=block.id,
194
+ sequence_number=next_seq,
195
+ description=block.description,
196
+ label=block.label,
197
+ value=block.value,
198
+ limit=block.limit,
199
+ metadata_=block.metadata_,
200
+ actor_type=ActorType.LETTA_AGENT if agent_id else ActorType.LETTA_USER,
201
+ actor_id=agent_id if agent_id else actor.id,
202
+ )
203
+ history_entry.create(session, actor=actor, no_commit=True)
204
+
205
+ # 6) Update the block’s pointer to the new checkpoint
206
+ block.current_history_entry_id = history_entry.id
207
+
208
+ # 7) Flush changes, then commit once
209
+ block = block.update(db_session=session, actor=actor, no_commit=True)
210
+ session.commit()
211
+
212
+ return block.to_pydantic()
213
+
214
+ @enforce_types
215
+ def _move_block_to_sequence(self, session: Session, block: BlockModel, target_seq: int, actor: PydanticUser) -> BlockModel:
216
+ """
217
+ Internal helper that moves the 'block' to the specified 'target_seq' within BlockHistory.
218
+ 1) Find the BlockHistory row at sequence_number=target_seq
219
+ 2) Copy fields into the block
220
+ 3) Update and flush (no_commit=True) - the caller is responsible for final commit
221
+
222
+ Raises:
223
+ NoResultFound: if no BlockHistory row for (block_id, target_seq)
224
+ """
225
+ if not block.id:
226
+ raise ValueError("Block is missing an ID. Cannot move sequence.")
227
+
228
+ target_entry = (
229
+ session.query(BlockHistory)
230
+ .filter(
231
+ BlockHistory.block_id == block.id,
232
+ BlockHistory.sequence_number == target_seq,
233
+ )
234
+ .one_or_none()
235
+ )
236
+ if not target_entry:
237
+ raise NoResultFound(f"No BlockHistory row found for block_id={block.id} at sequence={target_seq}")
238
+
239
+ # Copy fields from target_entry to block
240
+ block.description = target_entry.description # type: ignore
241
+ block.label = target_entry.label # type: ignore
242
+ block.value = target_entry.value # type: ignore
243
+ block.limit = target_entry.limit # type: ignore
244
+ block.metadata_ = target_entry.metadata_ # type: ignore
245
+ block.current_history_entry_id = target_entry.id # type: ignore
246
+
247
+ # Update in DB (optimistic locking).
248
+ # We'll do a flush now; the caller does final commit.
249
+ updated_block = block.update(db_session=session, actor=actor, no_commit=True)
250
+ return updated_block
251
+
252
+ @enforce_types
253
+ def undo_checkpoint_block(self, block_id: str, actor: PydanticUser, use_preloaded_block: Optional[BlockModel] = None) -> PydanticBlock:
254
+ """
255
+ Move the block to the immediately previous checkpoint in BlockHistory.
256
+ If older sequences have been pruned, we jump to the largest sequence
257
+ number that is still < current_seq.
258
+ """
259
+ with self.session_maker() as session:
260
+ # 1) Load the current block
261
+ block = (
262
+ session.merge(use_preloaded_block)
263
+ if use_preloaded_block
264
+ else BlockModel.read(db_session=session, identifier=block_id, actor=actor)
265
+ )
266
+
267
+ if not block.current_history_entry_id:
268
+ raise ValueError(f"Block {block_id} has no history entry - cannot undo.")
269
+
270
+ current_entry = session.get(BlockHistory, block.current_history_entry_id)
271
+ if not current_entry:
272
+ raise NoResultFound(f"BlockHistory row not found for id={block.current_history_entry_id}")
273
+
274
+ current_seq = current_entry.sequence_number
275
+
276
+ # 2) Find the largest sequence < current_seq
277
+ previous_entry = (
278
+ session.query(BlockHistory)
279
+ .filter(BlockHistory.block_id == block.id, BlockHistory.sequence_number < current_seq)
280
+ .order_by(BlockHistory.sequence_number.desc())
281
+ .first()
282
+ )
283
+ if not previous_entry:
284
+ # No earlier checkpoint available
285
+ raise ValueError(f"Block {block_id} is already at the earliest checkpoint (seq={current_seq}). Cannot undo further.")
286
+
287
+ # 3) Move to that sequence
288
+ block = self._move_block_to_sequence(session, block, previous_entry.sequence_number, actor)
289
+
290
+ # 4) Commit
291
+ session.commit()
292
+ return block.to_pydantic()
293
+
294
+ @enforce_types
295
+ def redo_checkpoint_block(self, block_id: str, actor: PydanticUser, use_preloaded_block: Optional[BlockModel] = None) -> PydanticBlock:
296
+ """
297
+ Move the block to the next checkpoint if it exists.
298
+ If some middle checkpoints have been pruned, we jump to the smallest
299
+ sequence > current_seq that remains.
300
+ """
301
+ with self.session_maker() as session:
302
+ block = (
303
+ session.merge(use_preloaded_block)
304
+ if use_preloaded_block
305
+ else BlockModel.read(db_session=session, identifier=block_id, actor=actor)
306
+ )
307
+
308
+ if not block.current_history_entry_id:
309
+ raise ValueError(f"Block {block_id} has no history entry - cannot redo.")
310
+
311
+ current_entry = session.get(BlockHistory, block.current_history_entry_id)
312
+ if not current_entry:
313
+ raise NoResultFound(f"BlockHistory row not found for id={block.current_history_entry_id}")
314
+
315
+ current_seq = current_entry.sequence_number
316
+
317
+ # Find the smallest sequence that is > current_seq
318
+ next_entry = (
319
+ session.query(BlockHistory)
320
+ .filter(BlockHistory.block_id == block.id, BlockHistory.sequence_number > current_seq)
321
+ .order_by(BlockHistory.sequence_number.asc())
322
+ .first()
323
+ )
324
+ if not next_entry:
325
+ raise ValueError(f"Block {block_id} is at the highest checkpoint (seq={current_seq}). Cannot redo further.")
326
+
327
+ block = self._move_block_to_sequence(session, block, next_entry.sequence_number, actor)
328
+
329
+ session.commit()
330
+ return block.to_pydantic()
@@ -71,11 +71,20 @@ class GroupManager:
71
71
  case ManagerType.supervisor:
72
72
  new_group.manager_type = ManagerType.supervisor
73
73
  new_group.manager_agent_id = group.manager_config.manager_agent_id
74
+ case ManagerType.sleeptime:
75
+ new_group.manager_type = ManagerType.sleeptime
76
+ new_group.manager_agent_id = group.manager_config.manager_agent_id
77
+ new_group.sleeptime_agent_frequency = group.manager_config.sleeptime_agent_frequency
78
+ if new_group.sleeptime_agent_frequency:
79
+ new_group.turns_counter = 0
74
80
  case _:
75
81
  raise ValueError(f"Unsupported manager type: {group.manager_config.manager_type}")
76
82
 
77
83
  self._process_agent_relationship(session=session, group=new_group, agent_ids=group.agent_ids, allow_partial=False)
78
84
 
85
+ if group.shared_block_ids:
86
+ self._process_shared_block_relationship(session=session, group=new_group, block_ids=group.shared_block_ids)
87
+
79
88
  new_group.create(session, actor=actor)
80
89
  return new_group.to_pydantic()
81
90
 
@@ -84,6 +93,7 @@ class GroupManager:
84
93
  with self.session_maker() as session:
85
94
  group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
86
95
 
96
+ sleeptime_agent_frequency = None
87
97
  max_turns = None
88
98
  termination_token = None
89
99
  manager_agent_id = None
@@ -99,9 +109,16 @@ class GroupManager:
99
109
  termination_token = group_update.manager_config.termination_token
100
110
  case ManagerType.supervisor:
101
111
  manager_agent_id = group_update.manager_config.manager_agent_id
112
+ case ManagerType.sleeptime:
113
+ manager_agent_id = group_update.manager_config.manager_agent_id
114
+ sleeptime_agent_frequency = group_update.manager_config.sleeptime_agent_frequency
115
+ if sleeptime_agent_frequency and group.turns_counter is None:
116
+ group.turns_counter = 0
102
117
  case _:
103
118
  raise ValueError(f"Unsupported manager type: {group_update.manager_config.manager_type}")
104
119
 
120
+ if sleeptime_agent_frequency:
121
+ group.sleeptime_agent_frequency = sleeptime_agent_frequency
105
122
  if max_turns:
106
123
  group.max_turns = max_turns
107
124
  if termination_token:
@@ -174,6 +191,30 @@ class GroupManager:
174
191
 
175
192
  session.commit()
176
193
 
194
+ @enforce_types
195
+ def bump_turns_counter(self, group_id: str, actor: PydanticUser) -> int:
196
+ with self.session_maker() as session:
197
+ # Ensure group is loadable by user
198
+ group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
199
+
200
+ # Update turns counter
201
+ group.turns_counter = (group.turns_counter + 1) % group.sleeptime_agent_frequency
202
+ group.update(session, actor=actor)
203
+ return group.turns_counter
204
+
205
+ @enforce_types
206
+ def get_last_processed_message_id_and_update(self, group_id: str, last_processed_message_id: str, actor: PydanticUser) -> str:
207
+ with self.session_maker() as session:
208
+ # Ensure group is loadable by user
209
+ group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
210
+
211
+ # Update last processed message id
212
+ prev_last_processed_message_id = group.last_processed_message_id
213
+ group.last_processed_message_id = last_processed_message_id
214
+ group.update(session, actor=actor)
215
+
216
+ return prev_last_processed_message_id
217
+
177
218
  def _process_agent_relationship(self, session: Session, group: GroupModel, agent_ids: List[str], allow_partial=False, replace=True):
178
219
  if not agent_ids:
179
220
  if replace:
@@ -203,3 +244,30 @@ class GroupManager:
203
244
  setattr(group, "agent_ids", agent_ids)
204
245
  else:
205
246
  raise ValueError("Extend relationship is not supported for groups.")
247
+
248
+ def _process_shared_block_relationship(
249
+ self,
250
+ session: Session,
251
+ group: GroupModel,
252
+ block_ids: List[str],
253
+ ):
254
+ """Process shared block relationships for a group and its agents."""
255
+ from letta.orm import Agent, Block, BlocksAgents
256
+
257
+ # Add blocks to group
258
+ blocks = session.query(Block).filter(Block.id.in_(block_ids)).all()
259
+ group.shared_blocks = blocks
260
+
261
+ # Add blocks to all agents
262
+ if group.agent_ids:
263
+ agents = session.query(Agent).filter(Agent.id.in_(group.agent_ids)).all()
264
+ for agent in agents:
265
+ for block in blocks:
266
+ session.add(BlocksAgents(agent_id=agent.id, block_id=block.id, block_label=block.label))
267
+
268
+ # Add blocks to manager agent if exists
269
+ if group.manager_agent_id:
270
+ manager_agent = session.query(Agent).filter(Agent.id == group.manager_agent_id).first()
271
+ if manager_agent:
272
+ for block in blocks:
273
+ session.add(BlocksAgents(agent_id=manager_agent.id, block_id=block.id, block_label=block.label))
@@ -89,13 +89,15 @@ def _process_tags(agent: AgentModel, tags: List[str], replace=True):
89
89
  agent.tags.extend([tag for tag in new_tags if tag.tag not in existing_tags])
90
90
 
91
91
 
92
- def derive_system_message(agent_type: AgentType, system: Optional[str] = None):
92
+ def derive_system_message(agent_type: AgentType, enable_sleeptime: Optional[bool] = None, system: Optional[str] = None):
93
93
  if system is None:
94
94
  # TODO: don't hardcode
95
- if agent_type == AgentType.memgpt_agent:
95
+ if agent_type == AgentType.memgpt_agent and not enable_sleeptime:
96
96
  system = gpt_system.get_system_text("memgpt_chat")
97
- elif agent_type == AgentType.offline_memory_agent:
98
- system = gpt_system.get_system_text("memgpt_offline_memory")
97
+ elif agent_type == AgentType.memgpt_agent and enable_sleeptime:
98
+ system = gpt_system.get_system_text("memgpt_sleeptime_chat")
99
+ elif agent_type == AgentType.sleeptime_agent:
100
+ system = gpt_system.get_system_text("sleeptime")
99
101
  else:
100
102
  raise ValueError(f"Invalid agent type: {agent_type}")
101
103
 
@@ -0,0 +1,139 @@
1
+ import datetime
2
+ from typing import Optional
3
+
4
+ from anthropic.types.beta.messages import BetaMessageBatch, BetaMessageBatchIndividualResponse
5
+
6
+ from letta.log import get_logger
7
+ from letta.orm.llm_batch_items import LLMBatchItem
8
+ from letta.orm.llm_batch_job import LLMBatchJob
9
+ from letta.schemas.agent import AgentStepState
10
+ from letta.schemas.enums import AgentStepStatus, JobStatus
11
+ from letta.schemas.llm_batch_job import LLMBatchItem as PydanticLLMBatchItem
12
+ from letta.schemas.llm_batch_job import LLMBatchJob as PydanticLLMBatchJob
13
+ from letta.schemas.llm_config import LLMConfig
14
+ from letta.schemas.user import User as PydanticUser
15
+ from letta.utils import enforce_types
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class LLMBatchManager:
21
+ """Manager for handling both LLMBatchJob and LLMBatchItem operations."""
22
+
23
+ def __init__(self):
24
+ from letta.server.db import db_context
25
+
26
+ self.session_maker = db_context
27
+
28
+ @enforce_types
29
+ def create_batch_request(
30
+ self,
31
+ llm_provider: str,
32
+ create_batch_response: BetaMessageBatch,
33
+ actor: PydanticUser,
34
+ status: JobStatus = JobStatus.created,
35
+ ) -> PydanticLLMBatchJob:
36
+ """Create a new LLM batch job."""
37
+ with self.session_maker() as session:
38
+ batch = LLMBatchJob(
39
+ status=status,
40
+ llm_provider=llm_provider,
41
+ create_batch_response=create_batch_response,
42
+ organization_id=actor.organization_id,
43
+ )
44
+ batch.create(session, actor=actor)
45
+ return batch.to_pydantic()
46
+
47
+ @enforce_types
48
+ def get_batch_request_by_id(self, batch_id: str, actor: PydanticUser) -> PydanticLLMBatchJob:
49
+ """Retrieve a single batch job by ID."""
50
+ with self.session_maker() as session:
51
+ batch = LLMBatchJob.read(db_session=session, identifier=batch_id, actor=actor)
52
+ return batch.to_pydantic()
53
+
54
+ @enforce_types
55
+ def update_batch_status(
56
+ self,
57
+ batch_id: str,
58
+ status: JobStatus,
59
+ actor: PydanticUser,
60
+ latest_polling_response: Optional[BetaMessageBatch] = None,
61
+ ) -> PydanticLLMBatchJob:
62
+ """Update a batch job’s status and optionally its polling response."""
63
+ with self.session_maker() as session:
64
+ batch = LLMBatchJob.read(db_session=session, identifier=batch_id, actor=actor)
65
+ batch.status = status
66
+ batch.latest_polling_response = latest_polling_response
67
+ batch.last_polled_at = datetime.datetime.now(datetime.timezone.utc)
68
+ return batch.update(db_session=session, actor=actor).to_pydantic()
69
+
70
+ @enforce_types
71
+ def delete_batch_request(self, batch_id: str, actor: PydanticUser) -> None:
72
+ """Hard delete a batch job by ID."""
73
+ with self.session_maker() as session:
74
+ batch = LLMBatchJob.read(db_session=session, identifier=batch_id, actor=actor)
75
+ batch.hard_delete(db_session=session, actor=actor)
76
+
77
+ @enforce_types
78
+ def create_batch_item(
79
+ self,
80
+ batch_id: str,
81
+ agent_id: str,
82
+ llm_config: LLMConfig,
83
+ actor: PydanticUser,
84
+ request_status: JobStatus = JobStatus.created,
85
+ step_status: AgentStepStatus = AgentStepStatus.paused,
86
+ step_state: Optional[AgentStepState] = None,
87
+ ) -> PydanticLLMBatchItem:
88
+ """Create a new batch item."""
89
+ with self.session_maker() as session:
90
+ item = LLMBatchItem(
91
+ batch_id=batch_id,
92
+ agent_id=agent_id,
93
+ llm_config=llm_config,
94
+ request_status=request_status,
95
+ step_status=step_status,
96
+ step_state=step_state,
97
+ organization_id=actor.organization_id,
98
+ )
99
+ item.create(session, actor=actor)
100
+ return item.to_pydantic()
101
+
102
+ @enforce_types
103
+ def get_batch_item_by_id(self, item_id: str, actor: PydanticUser) -> PydanticLLMBatchItem:
104
+ """Retrieve a single batch item by ID."""
105
+ with self.session_maker() as session:
106
+ item = LLMBatchItem.read(db_session=session, identifier=item_id, actor=actor)
107
+ return item.to_pydantic()
108
+
109
+ @enforce_types
110
+ def update_batch_item(
111
+ self,
112
+ item_id: str,
113
+ actor: PydanticUser,
114
+ request_status: Optional[JobStatus] = None,
115
+ step_status: Optional[AgentStepStatus] = None,
116
+ llm_request_response: Optional[BetaMessageBatchIndividualResponse] = None,
117
+ step_state: Optional[AgentStepState] = None,
118
+ ) -> PydanticLLMBatchItem:
119
+ """Update fields on a batch item."""
120
+ with self.session_maker() as session:
121
+ item = LLMBatchItem.read(db_session=session, identifier=item_id, actor=actor)
122
+
123
+ if request_status:
124
+ item.request_status = request_status
125
+ if step_status:
126
+ item.step_status = step_status
127
+ if llm_request_response:
128
+ item.batch_request_result = llm_request_response
129
+ if step_state:
130
+ item.step_state = step_state
131
+
132
+ return item.update(db_session=session, actor=actor).to_pydantic()
133
+
134
+ @enforce_types
135
+ def delete_batch_item(self, item_id: str, actor: PydanticUser) -> None:
136
+ """Hard delete a batch item by ID."""
137
+ with self.session_maker() as session:
138
+ item = LLMBatchItem.read(db_session=session, identifier=item_id, actor=actor)
139
+ item.hard_delete(db_session=session, actor=actor)
@@ -1,7 +1,7 @@
1
1
  import json
2
2
  from typing import List, Optional, Sequence
3
3
 
4
- from sqlalchemy import and_, exists, func, or_, select, text
4
+ from sqlalchemy import exists, func, select, text
5
5
 
6
6
  from letta.log import get_logger
7
7
  from letta.orm.agent import Agent as AgentModel
@@ -270,19 +270,20 @@ class MessageManager:
270
270
  Most performant query to list messages for an agent by directly querying the Message table.
271
271
 
272
272
  This function filters by the agent_id (leveraging the index on messages.agent_id)
273
- and applies efficient pagination using (created_at, id) as the cursor.
273
+ and applies pagination using sequence_id as the cursor.
274
274
  If query_text is provided, it will filter messages whose text content partially matches the query.
275
275
  If role is provided, it will filter messages by the specified role.
276
276
 
277
277
  Args:
278
278
  agent_id: The ID of the agent whose messages are queried.
279
279
  actor: The user performing the action (used for permission checks).
280
- after: A message ID; if provided, only messages *after* this message (per sort order) are returned.
281
- before: A message ID; if provided, only messages *before* this message are returned.
280
+ after: A message ID; if provided, only messages *after* this message (by sequence_id) are returned.
281
+ before: A message ID; if provided, only messages *before* this message (by sequence_id) are returned.
282
282
  query_text: Optional string to partially match the message text content.
283
283
  roles: Optional MessageRole to filter messages by role.
284
284
  limit: Maximum number of messages to return.
285
- ascending: If True, sort by (created_at, id) ascending; if False, sort descending.
285
+ ascending: If True, sort by sequence_id ascending; if False, sort descending.
286
+ group_id: Optional group ID to filter messages by group_id.
286
287
 
287
288
  Returns:
288
289
  List[PydanticMessage]: A list of messages (converted via .to_pydantic()).
@@ -290,6 +291,7 @@ class MessageManager:
290
291
  Raises:
291
292
  NoResultFound: If the provided after/before message IDs do not exist.
292
293
  """
294
+
293
295
  with self.session_maker() as session:
294
296
  # Permission check: raise if the agent doesn't exist or actor is not allowed.
295
297
  AgentModel.read(db_session=session, identifier=agent_id, actor=actor)
@@ -301,7 +303,7 @@ class MessageManager:
301
303
  if group_id:
302
304
  query = query.filter(MessageModel.group_id == group_id)
303
305
 
304
- # If query_text is provided, filter messages using subquery.
306
+ # If query_text is provided, filter messages using subquery + json_array_elements.
305
307
  if query_text:
306
308
  content_element = func.json_array_elements(MessageModel.content).alias("content_element")
307
309
  query = query.filter(
@@ -313,48 +315,32 @@ class MessageManager:
313
315
  )
314
316
  )
315
317
 
316
- # If role is provided, filter messages by role.
318
+ # If role(s) are provided, filter messages by those roles.
317
319
  if roles:
318
320
  role_values = [r.value for r in roles]
319
321
  query = query.filter(MessageModel.role.in_(role_values))
320
322
 
321
323
  # Apply 'after' pagination if specified.
322
324
  if after:
323
- after_ref = session.query(MessageModel.created_at, MessageModel.id).filter(MessageModel.id == after).limit(1).one_or_none()
325
+ after_ref = session.query(MessageModel.sequence_id).filter(MessageModel.id == after).one_or_none()
324
326
  if not after_ref:
325
327
  raise NoResultFound(f"No message found with id '{after}' for agent '{agent_id}'.")
326
- query = query.filter(
327
- or_(
328
- MessageModel.created_at > after_ref.created_at,
329
- and_(
330
- MessageModel.created_at == after_ref.created_at,
331
- MessageModel.id > after_ref.id,
332
- ),
333
- )
334
- )
328
+ # Filter out any messages with a sequence_id <= after_ref.sequence_id
329
+ query = query.filter(MessageModel.sequence_id > after_ref.sequence_id)
335
330
 
336
331
  # Apply 'before' pagination if specified.
337
332
  if before:
338
- before_ref = (
339
- session.query(MessageModel.created_at, MessageModel.id).filter(MessageModel.id == before).limit(1).one_or_none()
340
- )
333
+ before_ref = session.query(MessageModel.sequence_id).filter(MessageModel.id == before).one_or_none()
341
334
  if not before_ref:
342
335
  raise NoResultFound(f"No message found with id '{before}' for agent '{agent_id}'.")
343
- query = query.filter(
344
- or_(
345
- MessageModel.created_at < before_ref.created_at,
346
- and_(
347
- MessageModel.created_at == before_ref.created_at,
348
- MessageModel.id < before_ref.id,
349
- ),
350
- )
351
- )
336
+ # Filter out any messages with a sequence_id >= before_ref.sequence_id
337
+ query = query.filter(MessageModel.sequence_id < before_ref.sequence_id)
352
338
 
353
339
  # Apply ordering based on the ascending flag.
354
340
  if ascending:
355
- query = query.order_by(MessageModel.created_at.asc(), MessageModel.id.asc())
341
+ query = query.order_by(MessageModel.sequence_id.asc())
356
342
  else:
357
- query = query.order_by(MessageModel.created_at.desc(), MessageModel.id.desc())
343
+ query = query.order_by(MessageModel.sequence_id.desc())
358
344
 
359
345
  # Limit the number of results.
360
346
  query = query.limit(limit)
@@ -387,9 +387,7 @@ class ToolExecutionSandbox:
387
387
  sbx = Sandbox(sandbox_config.get_e2b_config().template, metadata={self.METADATA_CONFIG_STATE_KEY: state_hash})
388
388
  else:
389
389
  # no template
390
- sbx = Sandbox(
391
- metadata={self.METADATA_CONFIG_STATE_KEY: state_hash}, **e2b_config.model_dump(to_orm=True, exclude={"pip_requirements"})
392
- )
390
+ sbx = Sandbox(metadata={self.METADATA_CONFIG_STATE_KEY: state_hash}, **e2b_config.model_dump(exclude={"pip_requirements"}))
393
391
 
394
392
  # install pip requirements
395
393
  if e2b_config.pip_requirements:
@@ -1,4 +1,3 @@
1
- import ast
2
1
  import math
3
2
  from abc import ABC, abstractmethod
4
3
  from typing import Any, Optional, Tuple
@@ -15,7 +14,9 @@ from letta.schemas.user import User
15
14
  from letta.services.agent_manager import AgentManager
16
15
  from letta.services.message_manager import MessageManager
17
16
  from letta.services.passage_manager import PassageManager
18
- from letta.services.tool_executor.async_tool_execution_sandbox import AsyncToolExecutionSandbox
17
+ from letta.services.tool_sandbox.e2b_sandbox import AsyncToolSandboxE2B
18
+ from letta.services.tool_sandbox.local_sandbox import AsyncToolSandboxLocal
19
+ from letta.settings import tool_settings
19
20
  from letta.utils import get_friendly_error_msg
20
21
 
21
22
 
@@ -40,6 +41,7 @@ class LettaCoreToolExecutor(ToolExecutor):
40
41
  "send_message": self.send_message,
41
42
  "conversation_search": self.conversation_search,
42
43
  "archival_memory_search": self.archival_memory_search,
44
+ "archival_memory_insert": self.archival_memory_insert,
43
45
  }
44
46
 
45
47
  if function_name not in function_map:
@@ -334,16 +336,13 @@ class SandboxToolExecutor(ToolExecutor):
334
336
 
335
337
  agent_state_copy = self._create_agent_state_copy(agent_state)
336
338
 
337
- # TODO: This is brittle, think about better way to do this?
338
- if "agent_state" in self.parse_function_arguments(tool.source_code, tool.name):
339
- inject_agent_state = True
339
+ # Execute in sandbox depending on API key
340
+ if tool_settings.e2b_api_key:
341
+ sandbox = AsyncToolSandboxE2B(function_name, function_args, actor, tool_object=tool)
340
342
  else:
341
- inject_agent_state = False
343
+ sandbox = AsyncToolSandboxLocal(function_name, function_args, actor, tool_object=tool)
342
344
 
343
- # Execute in sandbox
344
- sandbox_run_result = await AsyncToolExecutionSandbox(function_name, function_args, actor, tool_object=tool).run(
345
- agent_state=agent_state_copy, inject_agent_state=inject_agent_state
346
- )
345
+ sandbox_run_result = await sandbox.run(agent_state=agent_state_copy)
347
346
 
348
347
  function_response, updated_agent_state = sandbox_run_result.func_return, sandbox_run_result.agent_state
349
348
 
@@ -371,16 +370,6 @@ class SandboxToolExecutor(ToolExecutor):
371
370
  # This is defensive programming - we try to coerce but fall back if it fails
372
371
  return function_args
373
372
 
374
- def parse_function_arguments(self, source_code: str, tool_name: str):
375
- """Get arguments of a function from its source code"""
376
- tree = ast.parse(source_code)
377
- args = []
378
- for node in ast.walk(tree):
379
- if isinstance(node, ast.FunctionDef) and node.name == tool_name:
380
- for arg in node.args.args:
381
- args.append(arg.arg)
382
- return args
383
-
384
373
  def _create_agent_state_copy(self, agent_state: AgentState):
385
374
  """Create a copy of agent state for sandbox execution."""
386
375
  agent_state_copy = agent_state.__deepcopy__()