letta-nightly 0.7.20.dev20250521104258__py3-none-any.whl → 0.7.21.dev20250522104246__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 (66) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +290 -3
  3. letta/agents/base_agent.py +0 -55
  4. letta/agents/helpers.py +5 -0
  5. letta/agents/letta_agent.py +314 -64
  6. letta/agents/letta_agent_batch.py +102 -55
  7. letta/agents/voice_agent.py +5 -5
  8. letta/client/client.py +9 -18
  9. letta/constants.py +55 -1
  10. letta/functions/function_sets/builtin.py +27 -0
  11. letta/groups/sleeptime_multi_agent_v2.py +1 -1
  12. letta/interfaces/anthropic_streaming_interface.py +10 -1
  13. letta/interfaces/openai_streaming_interface.py +9 -2
  14. letta/llm_api/anthropic.py +21 -2
  15. letta/llm_api/anthropic_client.py +33 -6
  16. letta/llm_api/google_ai_client.py +136 -423
  17. letta/llm_api/google_vertex_client.py +173 -22
  18. letta/llm_api/llm_api_tools.py +27 -0
  19. letta/llm_api/llm_client.py +1 -1
  20. letta/llm_api/llm_client_base.py +32 -21
  21. letta/llm_api/openai.py +57 -0
  22. letta/llm_api/openai_client.py +7 -11
  23. letta/memory.py +0 -1
  24. letta/orm/__init__.py +1 -0
  25. letta/orm/enums.py +1 -0
  26. letta/orm/provider_trace.py +26 -0
  27. letta/orm/step.py +1 -0
  28. letta/schemas/provider_trace.py +43 -0
  29. letta/schemas/providers.py +210 -65
  30. letta/schemas/step.py +1 -0
  31. letta/schemas/tool.py +4 -0
  32. letta/server/db.py +37 -19
  33. letta/server/rest_api/routers/v1/__init__.py +2 -0
  34. letta/server/rest_api/routers/v1/agents.py +57 -34
  35. letta/server/rest_api/routers/v1/blocks.py +3 -3
  36. letta/server/rest_api/routers/v1/identities.py +24 -26
  37. letta/server/rest_api/routers/v1/jobs.py +3 -3
  38. letta/server/rest_api/routers/v1/llms.py +13 -8
  39. letta/server/rest_api/routers/v1/sandbox_configs.py +6 -6
  40. letta/server/rest_api/routers/v1/tags.py +3 -3
  41. letta/server/rest_api/routers/v1/telemetry.py +18 -0
  42. letta/server/rest_api/routers/v1/tools.py +6 -6
  43. letta/server/rest_api/streaming_response.py +105 -0
  44. letta/server/rest_api/utils.py +4 -0
  45. letta/server/server.py +140 -1
  46. letta/services/agent_manager.py +251 -18
  47. letta/services/block_manager.py +52 -37
  48. letta/services/helpers/noop_helper.py +10 -0
  49. letta/services/identity_manager.py +43 -38
  50. letta/services/job_manager.py +29 -0
  51. letta/services/message_manager.py +111 -0
  52. letta/services/sandbox_config_manager.py +36 -0
  53. letta/services/step_manager.py +146 -0
  54. letta/services/telemetry_manager.py +58 -0
  55. letta/services/tool_executor/tool_execution_manager.py +49 -5
  56. letta/services/tool_executor/tool_execution_sandbox.py +47 -0
  57. letta/services/tool_executor/tool_executor.py +236 -7
  58. letta/services/tool_manager.py +160 -1
  59. letta/services/tool_sandbox/e2b_sandbox.py +65 -3
  60. letta/settings.py +10 -2
  61. letta/tracing.py +5 -5
  62. {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/METADATA +3 -2
  63. {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/RECORD +66 -59
  64. {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/LICENSE +0 -0
  65. {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/WHEEL +0 -0
  66. {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  from typing import Dict, List, Optional
2
3
 
3
4
  from sqlalchemy import select
@@ -82,39 +83,64 @@ class BlockManager:
82
83
  return block.to_pydantic()
83
84
 
84
85
  @enforce_types
85
- def get_blocks(
86
+ async def get_blocks_async(
86
87
  self,
87
88
  actor: PydanticUser,
88
89
  label: Optional[str] = None,
89
90
  is_template: Optional[bool] = None,
90
91
  template_name: Optional[str] = None,
91
- identifier_keys: Optional[List[str]] = None,
92
92
  identity_id: Optional[str] = None,
93
- id: Optional[str] = None,
94
- after: Optional[str] = None,
93
+ identifier_keys: Optional[List[str]] = None,
95
94
  limit: Optional[int] = 50,
96
95
  ) -> List[PydanticBlock]:
97
- """Retrieve blocks based on various optional filters."""
98
- with db_registry.session() as session:
99
- # Prepare filters
100
- filters = {"organization_id": actor.organization_id}
96
+ """Async version of get_blocks method. Retrieve blocks based on various optional filters."""
97
+ from sqlalchemy import select
98
+ from sqlalchemy.orm import noload
99
+
100
+ from letta.orm.sqlalchemy_base import AccessType
101
+
102
+ async with db_registry.async_session() as session:
103
+ # Start with a basic query
104
+ query = select(BlockModel)
105
+
106
+ # Explicitly avoid loading relationships
107
+ query = query.options(noload(BlockModel.agents), noload(BlockModel.identities), noload(BlockModel.groups))
108
+
109
+ # Apply access control
110
+ query = BlockModel.apply_access_predicate(query, actor, ["read"], AccessType.ORGANIZATION)
111
+
112
+ # Add filters
113
+ query = query.where(BlockModel.organization_id == actor.organization_id)
101
114
  if label:
102
- filters["label"] = label
115
+ query = query.where(BlockModel.label == label)
116
+
103
117
  if is_template is not None:
104
- filters["is_template"] = is_template
118
+ query = query.where(BlockModel.is_template == is_template)
119
+
105
120
  if template_name:
106
- filters["template_name"] = template_name
107
- if id:
108
- filters["id"] = id
109
-
110
- blocks = BlockModel.list(
111
- db_session=session,
112
- after=after,
113
- limit=limit,
114
- identifier_keys=identifier_keys,
115
- identity_id=identity_id,
116
- **filters,
117
- )
121
+ query = query.where(BlockModel.template_name == template_name)
122
+
123
+ if identifier_keys:
124
+ query = (
125
+ query.join(BlockModel.identities)
126
+ .filter(BlockModel.identities.property.mapper.class_.identifier_key.in_(identifier_keys))
127
+ .distinct(BlockModel.id)
128
+ )
129
+
130
+ if identity_id:
131
+ query = (
132
+ query.join(BlockModel.identities)
133
+ .filter(BlockModel.identities.property.mapper.class_.id == identity_id)
134
+ .distinct(BlockModel.id)
135
+ )
136
+
137
+ # Add limit
138
+ if limit:
139
+ query = query.limit(limit)
140
+
141
+ # Execute the query
142
+ result = await session.execute(query)
143
+ blocks = result.scalars().all()
118
144
 
119
145
  return [block.to_pydantic() for block in blocks]
120
146
 
@@ -190,15 +216,6 @@ class BlockManager:
190
216
  except NoResultFound:
191
217
  return None
192
218
 
193
- @enforce_types
194
- def get_all_blocks_by_ids(self, block_ids: List[str], actor: Optional[PydanticUser] = None) -> List[PydanticBlock]:
195
- """Retrieve blocks by their ids."""
196
- with db_registry.session() as session:
197
- blocks = [block.to_pydantic() for block in BlockModel.read_multiple(db_session=session, identifiers=block_ids, actor=actor)]
198
- # backwards compatibility. previous implementation added None for every block not found.
199
- blocks.extend([None for _ in range(len(block_ids) - len(blocks))])
200
- return blocks
201
-
202
219
  @enforce_types
203
220
  async def get_all_blocks_by_ids_async(self, block_ids: List[str], actor: Optional[PydanticUser] = None) -> List[PydanticBlock]:
204
221
  """Retrieve blocks by their ids without loading unnecessary relationships. Async implementation."""
@@ -247,16 +264,14 @@ class BlockManager:
247
264
  return pydantic_blocks
248
265
 
249
266
  @enforce_types
250
- def get_agents_for_block(self, block_id: str, actor: PydanticUser) -> List[PydanticAgentState]:
267
+ async def get_agents_for_block_async(self, block_id: str, actor: PydanticUser) -> List[PydanticAgentState]:
251
268
  """
252
269
  Retrieve all agents associated with a given block.
253
270
  """
254
- with db_registry.session() as session:
255
- block = BlockModel.read(db_session=session, identifier=block_id, actor=actor)
271
+ async with db_registry.async_session() as session:
272
+ block = await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
256
273
  agents_orm = block.agents
257
- agents_pydantic = [agent.to_pydantic() for agent in agents_orm]
258
-
259
- return agents_pydantic
274
+ return await asyncio.gather(*[agent.to_pydantic_async() for agent in agents_orm])
260
275
 
261
276
  @enforce_types
262
277
  def size(
@@ -0,0 +1,10 @@
1
+ def singleton(cls):
2
+ """Decorator to make a class a Singleton class."""
3
+ instances = {}
4
+
5
+ def get_instance(*args, **kwargs):
6
+ if cls not in instances:
7
+ instances[cls] = cls(*args, **kwargs)
8
+ return instances[cls]
9
+
10
+ return get_instance
@@ -1,6 +1,7 @@
1
1
  from typing import List, Optional
2
2
 
3
3
  from fastapi import HTTPException
4
+ from sqlalchemy import select
4
5
  from sqlalchemy.exc import NoResultFound
5
6
  from sqlalchemy.orm import Session
6
7
 
@@ -17,7 +18,7 @@ from letta.utils import enforce_types
17
18
  class IdentityManager:
18
19
 
19
20
  @enforce_types
20
- def list_identities(
21
+ async def list_identities_async(
21
22
  self,
22
23
  name: Optional[str] = None,
23
24
  project_id: Optional[str] = None,
@@ -28,7 +29,7 @@ class IdentityManager:
28
29
  limit: Optional[int] = 50,
29
30
  actor: PydanticUser = None,
30
31
  ) -> list[PydanticIdentity]:
31
- with db_registry.session() as session:
32
+ async with db_registry.async_session() as session:
32
33
  filters = {"organization_id": actor.organization_id}
33
34
  if project_id:
34
35
  filters["project_id"] = project_id
@@ -36,7 +37,7 @@ class IdentityManager:
36
37
  filters["identifier_key"] = identifier_key
37
38
  if identity_type:
38
39
  filters["identity_type"] = identity_type
39
- identities = IdentityModel.list(
40
+ identities = await IdentityModel.list_async(
40
41
  db_session=session,
41
42
  query_text=name,
42
43
  before=before,
@@ -47,17 +48,17 @@ class IdentityManager:
47
48
  return [identity.to_pydantic() for identity in identities]
48
49
 
49
50
  @enforce_types
50
- def get_identity(self, identity_id: str, actor: PydanticUser) -> PydanticIdentity:
51
- with db_registry.session() as session:
52
- identity = IdentityModel.read(db_session=session, identifier=identity_id, actor=actor)
51
+ async def get_identity_async(self, identity_id: str, actor: PydanticUser) -> PydanticIdentity:
52
+ async with db_registry.async_session() as session:
53
+ identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
53
54
  return identity.to_pydantic()
54
55
 
55
56
  @enforce_types
56
- def create_identity(self, identity: IdentityCreate, actor: PydanticUser) -> PydanticIdentity:
57
- with db_registry.session() as session:
57
+ async def create_identity_async(self, identity: IdentityCreate, actor: PydanticUser) -> PydanticIdentity:
58
+ async with db_registry.async_session() as session:
58
59
  new_identity = IdentityModel(**identity.model_dump(exclude={"agent_ids", "block_ids"}, exclude_unset=True))
59
60
  new_identity.organization_id = actor.organization_id
60
- self._process_relationship(
61
+ await self._process_relationship_async(
61
62
  session=session,
62
63
  identity=new_identity,
63
64
  relationship_name="agents",
@@ -65,7 +66,7 @@ class IdentityManager:
65
66
  item_ids=identity.agent_ids,
66
67
  allow_partial=False,
67
68
  )
68
- self._process_relationship(
69
+ await self._process_relationship_async(
69
70
  session=session,
70
71
  identity=new_identity,
71
72
  relationship_name="blocks",
@@ -73,13 +74,13 @@ class IdentityManager:
73
74
  item_ids=identity.block_ids,
74
75
  allow_partial=False,
75
76
  )
76
- new_identity.create(session, actor=actor)
77
+ await new_identity.create_async(session, actor=actor)
77
78
  return new_identity.to_pydantic()
78
79
 
79
80
  @enforce_types
80
- def upsert_identity(self, identity: IdentityUpsert, actor: PydanticUser) -> PydanticIdentity:
81
- with db_registry.session() as session:
82
- existing_identity = IdentityModel.read(
81
+ async def upsert_identity_async(self, identity: IdentityUpsert, actor: PydanticUser) -> PydanticIdentity:
82
+ async with db_registry.async_session() as session:
83
+ existing_identity = await IdentityModel.read_async(
83
84
  db_session=session,
84
85
  identifier_key=identity.identifier_key,
85
86
  project_id=identity.project_id,
@@ -88,7 +89,7 @@ class IdentityManager:
88
89
  )
89
90
 
90
91
  if existing_identity is None:
91
- return self.create_identity(identity=IdentityCreate(**identity.model_dump()), actor=actor)
92
+ return await self.create_identity_async(identity=IdentityCreate(**identity.model_dump()), actor=actor)
92
93
  else:
93
94
  identity_update = IdentityUpdate(
94
95
  name=identity.name,
@@ -97,25 +98,27 @@ class IdentityManager:
97
98
  agent_ids=identity.agent_ids,
98
99
  properties=identity.properties,
99
100
  )
100
- return self._update_identity(
101
+ return await self._update_identity_async(
101
102
  session=session, existing_identity=existing_identity, identity=identity_update, actor=actor, replace=True
102
103
  )
103
104
 
104
105
  @enforce_types
105
- def update_identity(self, identity_id: str, identity: IdentityUpdate, actor: PydanticUser, replace: bool = False) -> PydanticIdentity:
106
- with db_registry.session() as session:
106
+ async def update_identity_async(
107
+ self, identity_id: str, identity: IdentityUpdate, actor: PydanticUser, replace: bool = False
108
+ ) -> PydanticIdentity:
109
+ async with db_registry.async_session() as session:
107
110
  try:
108
- existing_identity = IdentityModel.read(db_session=session, identifier=identity_id, actor=actor)
111
+ existing_identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
109
112
  except NoResultFound:
110
113
  raise HTTPException(status_code=404, detail="Identity not found")
111
114
  if existing_identity.organization_id != actor.organization_id:
112
115
  raise HTTPException(status_code=403, detail="Forbidden")
113
116
 
114
- return self._update_identity(
117
+ return await self._update_identity_async(
115
118
  session=session, existing_identity=existing_identity, identity=identity, actor=actor, replace=replace
116
119
  )
117
120
 
118
- def _update_identity(
121
+ async def _update_identity_async(
119
122
  self,
120
123
  session: Session,
121
124
  existing_identity: IdentityModel,
@@ -139,7 +142,7 @@ class IdentityManager:
139
142
  existing_identity.properties = list(new_properties.values())
140
143
 
141
144
  if identity.agent_ids is not None:
142
- self._process_relationship(
145
+ await self._process_relationship_async(
143
146
  session=session,
144
147
  identity=existing_identity,
145
148
  relationship_name="agents",
@@ -149,7 +152,7 @@ class IdentityManager:
149
152
  replace=replace,
150
153
  )
151
154
  if identity.block_ids is not None:
152
- self._process_relationship(
155
+ await self._process_relationship_async(
153
156
  session=session,
154
157
  identity=existing_identity,
155
158
  relationship_name="blocks",
@@ -158,16 +161,18 @@ class IdentityManager:
158
161
  allow_partial=False,
159
162
  replace=replace,
160
163
  )
161
- existing_identity.update(session, actor=actor)
164
+ await existing_identity.update_async(session, actor=actor)
162
165
  return existing_identity.to_pydantic()
163
166
 
164
167
  @enforce_types
165
- def upsert_identity_properties(self, identity_id: str, properties: List[IdentityProperty], actor: PydanticUser) -> PydanticIdentity:
166
- with db_registry.session() as session:
167
- existing_identity = IdentityModel.read(db_session=session, identifier=identity_id, actor=actor)
168
+ async def upsert_identity_properties_async(
169
+ self, identity_id: str, properties: List[IdentityProperty], actor: PydanticUser
170
+ ) -> PydanticIdentity:
171
+ async with db_registry.async_session() as session:
172
+ existing_identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
168
173
  if existing_identity is None:
169
174
  raise HTTPException(status_code=404, detail="Identity not found")
170
- return self._update_identity(
175
+ return await self._update_identity_async(
171
176
  session=session,
172
177
  existing_identity=existing_identity,
173
178
  identity=IdentityUpdate(properties=properties),
@@ -176,28 +181,28 @@ class IdentityManager:
176
181
  )
177
182
 
178
183
  @enforce_types
179
- def delete_identity(self, identity_id: str, actor: PydanticUser) -> None:
180
- with db_registry.session() as session:
181
- identity = IdentityModel.read(db_session=session, identifier=identity_id)
184
+ async def delete_identity_async(self, identity_id: str, actor: PydanticUser) -> None:
185
+ async with db_registry.async_session() as session:
186
+ identity = await IdentityModel.read_async(db_session=session, identifier=identity_id, actor=actor)
182
187
  if identity is None:
183
188
  raise HTTPException(status_code=404, detail="Identity not found")
184
189
  if identity.organization_id != actor.organization_id:
185
190
  raise HTTPException(status_code=403, detail="Forbidden")
186
- session.delete(identity)
187
- session.commit()
191
+ await session.delete(identity)
192
+ await session.commit()
188
193
 
189
194
  @enforce_types
190
- def size(
195
+ async def size_async(
191
196
  self,
192
197
  actor: PydanticUser,
193
198
  ) -> int:
194
199
  """
195
200
  Get the total count of identities for the given user.
196
201
  """
197
- with db_registry.session() as session:
198
- return IdentityModel.size(db_session=session, actor=actor)
202
+ async with db_registry.async_session() as session:
203
+ return await IdentityModel.size_async(db_session=session, actor=actor)
199
204
 
200
- def _process_relationship(
205
+ async def _process_relationship_async(
201
206
  self,
202
207
  session: Session,
203
208
  identity: PydanticIdentity,
@@ -214,7 +219,7 @@ class IdentityManager:
214
219
  return
215
220
 
216
221
  # Retrieve models for the provided IDs
217
- found_items = session.query(model_class).filter(model_class.id.in_(item_ids)).all()
222
+ found_items = (await session.execute(select(model_class).where(model_class.id.in_(item_ids)))).scalars().all()
218
223
 
219
224
  # Validate all items are found if allow_partial is False
220
225
  if not allow_partial and len(found_items) != len(item_ids):
@@ -150,6 +150,35 @@ class JobManager:
150
150
  )
151
151
  return [job.to_pydantic() for job in jobs]
152
152
 
153
+ @enforce_types
154
+ async def list_jobs_async(
155
+ self,
156
+ actor: PydanticUser,
157
+ before: Optional[str] = None,
158
+ after: Optional[str] = None,
159
+ limit: Optional[int] = 50,
160
+ statuses: Optional[List[JobStatus]] = None,
161
+ job_type: JobType = JobType.JOB,
162
+ ascending: bool = True,
163
+ ) -> List[PydanticJob]:
164
+ """List all jobs with optional pagination and status filter."""
165
+ async with db_registry.async_session() as session:
166
+ filter_kwargs = {"user_id": actor.id, "job_type": job_type}
167
+
168
+ # Add status filter if provided
169
+ if statuses:
170
+ filter_kwargs["status"] = statuses
171
+
172
+ jobs = await JobModel.list_async(
173
+ db_session=session,
174
+ before=before,
175
+ after=after,
176
+ limit=limit,
177
+ ascending=ascending,
178
+ **filter_kwargs,
179
+ )
180
+ return [job.to_pydantic() for job in jobs]
181
+
153
182
  @enforce_types
154
183
  def delete_job_by_id(self, job_id: str, actor: PydanticUser) -> PydanticJob:
155
184
  """Delete a job by its ID."""
@@ -31,6 +31,16 @@ class MessageManager:
31
31
  except NoResultFound:
32
32
  return None
33
33
 
34
+ @enforce_types
35
+ async def get_message_by_id_async(self, message_id: str, actor: PydanticUser) -> Optional[PydanticMessage]:
36
+ """Fetch a message by ID."""
37
+ async with db_registry.async_session() as session:
38
+ try:
39
+ message = await MessageModel.read_async(db_session=session, identifier=message_id, actor=actor)
40
+ return message.to_pydantic()
41
+ except NoResultFound:
42
+ return None
43
+
34
44
  @enforce_types
35
45
  def get_messages_by_ids(self, message_ids: List[str], actor: PydanticUser) -> List[PydanticMessage]:
36
46
  """Fetch messages by ID and return them in the requested order."""
@@ -426,6 +436,107 @@ class MessageManager:
426
436
  results = query.all()
427
437
  return [msg.to_pydantic() for msg in results]
428
438
 
439
+ @enforce_types
440
+ async def list_messages_for_agent_async(
441
+ self,
442
+ agent_id: str,
443
+ actor: PydanticUser,
444
+ after: Optional[str] = None,
445
+ before: Optional[str] = None,
446
+ query_text: Optional[str] = None,
447
+ roles: Optional[Sequence[MessageRole]] = None,
448
+ limit: Optional[int] = 50,
449
+ ascending: bool = True,
450
+ group_id: Optional[str] = None,
451
+ ) -> List[PydanticMessage]:
452
+ """
453
+ Most performant query to list messages for an agent by directly querying the Message table.
454
+
455
+ This function filters by the agent_id (leveraging the index on messages.agent_id)
456
+ and applies pagination using sequence_id as the cursor.
457
+ If query_text is provided, it will filter messages whose text content partially matches the query.
458
+ If role is provided, it will filter messages by the specified role.
459
+
460
+ Args:
461
+ agent_id: The ID of the agent whose messages are queried.
462
+ actor: The user performing the action (used for permission checks).
463
+ after: A message ID; if provided, only messages *after* this message (by sequence_id) are returned.
464
+ before: A message ID; if provided, only messages *before* this message (by sequence_id) are returned.
465
+ query_text: Optional string to partially match the message text content.
466
+ roles: Optional MessageRole to filter messages by role.
467
+ limit: Maximum number of messages to return.
468
+ ascending: If True, sort by sequence_id ascending; if False, sort descending.
469
+ group_id: Optional group ID to filter messages by group_id.
470
+
471
+ Returns:
472
+ List[PydanticMessage]: A list of messages (converted via .to_pydantic()).
473
+
474
+ Raises:
475
+ NoResultFound: If the provided after/before message IDs do not exist.
476
+ """
477
+
478
+ async with db_registry.async_session() as session:
479
+ # Permission check: raise if the agent doesn't exist or actor is not allowed.
480
+ await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
481
+
482
+ # Build a query that directly filters the Message table by agent_id.
483
+ query = select(MessageModel).where(MessageModel.agent_id == agent_id)
484
+
485
+ # If group_id is provided, filter messages by group_id.
486
+ if group_id:
487
+ query = query.where(MessageModel.group_id == group_id)
488
+
489
+ # If query_text is provided, filter messages using subquery + json_array_elements.
490
+ if query_text:
491
+ content_element = func.json_array_elements(MessageModel.content).alias("content_element")
492
+ query = query.where(
493
+ exists(
494
+ select(1)
495
+ .select_from(content_element)
496
+ .where(text("content_element->>'type' = 'text' AND content_element->>'text' ILIKE :query_text"))
497
+ .params(query_text=f"%{query_text}%")
498
+ )
499
+ )
500
+
501
+ # If role(s) are provided, filter messages by those roles.
502
+ if roles:
503
+ role_values = [r.value for r in roles]
504
+ query = query.where(MessageModel.role.in_(role_values))
505
+
506
+ # Apply 'after' pagination if specified.
507
+ if after:
508
+ after_query = select(MessageModel.sequence_id).where(MessageModel.id == after)
509
+ after_result = await session.execute(after_query)
510
+ after_ref = after_result.one_or_none()
511
+ if not after_ref:
512
+ raise NoResultFound(f"No message found with id '{after}' for agent '{agent_id}'.")
513
+ # Filter out any messages with a sequence_id <= after_ref.sequence_id
514
+ query = query.where(MessageModel.sequence_id > after_ref.sequence_id)
515
+
516
+ # Apply 'before' pagination if specified.
517
+ if before:
518
+ before_query = select(MessageModel.sequence_id).where(MessageModel.id == before)
519
+ before_result = await session.execute(before_query)
520
+ before_ref = before_result.one_or_none()
521
+ if not before_ref:
522
+ raise NoResultFound(f"No message found with id '{before}' for agent '{agent_id}'.")
523
+ # Filter out any messages with a sequence_id >= before_ref.sequence_id
524
+ query = query.where(MessageModel.sequence_id < before_ref.sequence_id)
525
+
526
+ # Apply ordering based on the ascending flag.
527
+ if ascending:
528
+ query = query.order_by(MessageModel.sequence_id.asc())
529
+ else:
530
+ query = query.order_by(MessageModel.sequence_id.desc())
531
+
532
+ # Limit the number of results.
533
+ query = query.limit(limit)
534
+
535
+ # Execute and convert each Message to its Pydantic representation.
536
+ result = await session.execute(query)
537
+ results = result.scalars().all()
538
+ return [msg.to_pydantic() for msg in results]
539
+
429
540
  @enforce_types
430
541
  def delete_all_messages_for_agent(self, agent_id: str, actor: PydanticUser) -> int:
431
542
  """
@@ -122,6 +122,23 @@ class SandboxConfigManager:
122
122
  sandboxes = SandboxConfigModel.list(db_session=session, after=after, limit=limit, **kwargs)
123
123
  return [sandbox.to_pydantic() for sandbox in sandboxes]
124
124
 
125
+ @enforce_types
126
+ async def list_sandbox_configs_async(
127
+ self,
128
+ actor: PydanticUser,
129
+ after: Optional[str] = None,
130
+ limit: Optional[int] = 50,
131
+ sandbox_type: Optional[SandboxType] = None,
132
+ ) -> List[PydanticSandboxConfig]:
133
+ """List all sandbox configurations with optional pagination."""
134
+ kwargs = {"organization_id": actor.organization_id}
135
+ if sandbox_type:
136
+ kwargs.update({"type": sandbox_type})
137
+
138
+ async with db_registry.async_session() as session:
139
+ sandboxes = await SandboxConfigModel.list_async(db_session=session, after=after, limit=limit, **kwargs)
140
+ return [sandbox.to_pydantic() for sandbox in sandboxes]
141
+
125
142
  @enforce_types
126
143
  def get_sandbox_config_by_id(self, sandbox_config_id: str, actor: Optional[PydanticUser] = None) -> Optional[PydanticSandboxConfig]:
127
144
  """Retrieve a sandbox configuration by its ID."""
@@ -224,6 +241,25 @@ class SandboxConfigManager:
224
241
  )
225
242
  return [env_var.to_pydantic() for env_var in env_vars]
226
243
 
244
+ @enforce_types
245
+ async def list_sandbox_env_vars_async(
246
+ self,
247
+ sandbox_config_id: str,
248
+ actor: PydanticUser,
249
+ after: Optional[str] = None,
250
+ limit: Optional[int] = 50,
251
+ ) -> List[PydanticEnvVar]:
252
+ """List all sandbox environment variables with optional pagination."""
253
+ async with db_registry.async_session() as session:
254
+ env_vars = await SandboxEnvVarModel.list_async(
255
+ db_session=session,
256
+ after=after,
257
+ limit=limit,
258
+ organization_id=actor.organization_id,
259
+ sandbox_config_id=sandbox_config_id,
260
+ )
261
+ return [env_var.to_pydantic() for env_var in env_vars]
262
+
227
263
  @enforce_types
228
264
  def list_sandbox_env_vars_by_key(
229
265
  self, key: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50