letta-nightly 0.11.7.dev20250908104137__py3-none-any.whl → 0.11.7.dev20250910104051__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 (70) hide show
  1. letta/adapters/letta_llm_adapter.py +81 -0
  2. letta/adapters/letta_llm_request_adapter.py +111 -0
  3. letta/adapters/letta_llm_stream_adapter.py +169 -0
  4. letta/agents/base_agent.py +4 -1
  5. letta/agents/base_agent_v2.py +68 -0
  6. letta/agents/helpers.py +3 -5
  7. letta/agents/letta_agent.py +23 -12
  8. letta/agents/letta_agent_v2.py +1220 -0
  9. letta/agents/voice_agent.py +2 -1
  10. letta/constants.py +1 -1
  11. letta/errors.py +12 -0
  12. letta/functions/function_sets/base.py +53 -12
  13. letta/functions/schema_generator.py +1 -1
  14. letta/groups/sleeptime_multi_agent_v3.py +231 -0
  15. letta/helpers/tool_rule_solver.py +4 -0
  16. letta/helpers/tpuf_client.py +607 -34
  17. letta/interfaces/anthropic_streaming_interface.py +64 -24
  18. letta/interfaces/openai_streaming_interface.py +80 -37
  19. letta/llm_api/openai_client.py +45 -4
  20. letta/orm/block.py +1 -0
  21. letta/orm/group.py +1 -0
  22. letta/orm/source.py +8 -1
  23. letta/orm/step_metrics.py +10 -0
  24. letta/schemas/block.py +4 -0
  25. letta/schemas/enums.py +1 -0
  26. letta/schemas/group.py +8 -0
  27. letta/schemas/letta_message.py +1 -1
  28. letta/schemas/letta_request.py +2 -2
  29. letta/schemas/mcp.py +9 -1
  30. letta/schemas/message.py +23 -0
  31. letta/schemas/providers/ollama.py +1 -1
  32. letta/schemas/providers.py +1 -2
  33. letta/schemas/source.py +6 -0
  34. letta/schemas/step_metrics.py +2 -0
  35. letta/server/rest_api/routers/v1/__init__.py +2 -0
  36. letta/server/rest_api/routers/v1/agents.py +100 -5
  37. letta/server/rest_api/routers/v1/blocks.py +6 -0
  38. letta/server/rest_api/routers/v1/folders.py +23 -5
  39. letta/server/rest_api/routers/v1/groups.py +6 -0
  40. letta/server/rest_api/routers/v1/internal_templates.py +218 -12
  41. letta/server/rest_api/routers/v1/messages.py +14 -19
  42. letta/server/rest_api/routers/v1/runs.py +43 -28
  43. letta/server/rest_api/routers/v1/sources.py +23 -5
  44. letta/server/rest_api/routers/v1/tools.py +42 -0
  45. letta/server/rest_api/streaming_response.py +9 -1
  46. letta/server/server.py +2 -1
  47. letta/services/agent_manager.py +39 -59
  48. letta/services/agent_serialization_manager.py +22 -8
  49. letta/services/archive_manager.py +60 -9
  50. letta/services/block_manager.py +5 -0
  51. letta/services/file_processor/embedder/base_embedder.py +5 -0
  52. letta/services/file_processor/embedder/openai_embedder.py +4 -0
  53. letta/services/file_processor/embedder/pinecone_embedder.py +5 -1
  54. letta/services/file_processor/embedder/turbopuffer_embedder.py +71 -0
  55. letta/services/file_processor/file_processor.py +9 -7
  56. letta/services/group_manager.py +74 -11
  57. letta/services/mcp_manager.py +132 -26
  58. letta/services/message_manager.py +229 -125
  59. letta/services/passage_manager.py +2 -1
  60. letta/services/source_manager.py +23 -1
  61. letta/services/summarizer/summarizer.py +2 -0
  62. letta/services/tool_executor/core_tool_executor.py +2 -120
  63. letta/services/tool_executor/files_tool_executor.py +133 -8
  64. letta/settings.py +6 -0
  65. letta/utils.py +34 -1
  66. {letta_nightly-0.11.7.dev20250908104137.dist-info → letta_nightly-0.11.7.dev20250910104051.dist-info}/METADATA +2 -2
  67. {letta_nightly-0.11.7.dev20250908104137.dist-info → letta_nightly-0.11.7.dev20250910104051.dist-info}/RECORD +70 -63
  68. {letta_nightly-0.11.7.dev20250908104137.dist-info → letta_nightly-0.11.7.dev20250910104051.dist-info}/WHEEL +0 -0
  69. {letta_nightly-0.11.7.dev20250908104137.dist-info → letta_nightly-0.11.7.dev20250910104051.dist-info}/entry_points.txt +0 -0
  70. {letta_nightly-0.11.7.dev20250908104137.dist-info → letta_nightly-0.11.7.dev20250910104051.dist-info}/licenses/LICENSE +0 -0
@@ -15,7 +15,13 @@ from starlette.responses import Response, StreamingResponse
15
15
  from letta.agents.letta_agent import LettaAgent
16
16
  from letta.constants import AGENT_ID_PATTERN, DEFAULT_MAX_STEPS, DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG, REDIS_RUN_ID_PREFIX
17
17
  from letta.data_sources.redis_client import NoopAsyncRedisClient, get_redis_client
18
- from letta.errors import AgentExportIdMappingError, AgentExportProcessingError, AgentFileImportError, AgentNotFoundForExportError
18
+ from letta.errors import (
19
+ AgentExportIdMappingError,
20
+ AgentExportProcessingError,
21
+ AgentFileImportError,
22
+ AgentNotFoundForExportError,
23
+ PendingApprovalError,
24
+ )
19
25
  from letta.groups.sleeptime_multi_agent_v2 import SleeptimeMultiAgentV2
20
26
  from letta.helpers.datetime_helpers import get_utc_timestamp_ns
21
27
  from letta.log import get_logger
@@ -39,7 +45,7 @@ from letta.schemas.memory import (
39
45
  CreateArchivalMemory,
40
46
  Memory,
41
47
  )
42
- from letta.schemas.message import MessageCreate
48
+ from letta.schemas.message import MessageCreate, MessageSearchRequest, MessageSearchResult
43
49
  from letta.schemas.passage import Passage
44
50
  from letta.schemas.run import Run
45
51
  from letta.schemas.source import Source
@@ -1013,7 +1019,7 @@ async def search_archival_memory(
1013
1019
  end_datetime = end_datetime.isoformat() if end_datetime else None
1014
1020
 
1015
1021
  # Use the shared agent manager method
1016
- formatted_results, count = await server.agent_manager.search_agent_archival_memory_async(
1022
+ formatted_results = await server.agent_manager.search_agent_archival_memory_async(
1017
1023
  agent_id=agent_id,
1018
1024
  actor=actor,
1019
1025
  query=query,
@@ -1027,7 +1033,7 @@ async def search_archival_memory(
1027
1033
  # Convert to proper response schema
1028
1034
  search_results = [ArchivalMemorySearchResult(**result) for result in formatted_results]
1029
1035
 
1030
- return ArchivalMemorySearchResponse(results=search_results, count=count)
1036
+ return ArchivalMemorySearchResponse(results=search_results, count=len(formatted_results))
1031
1037
 
1032
1038
  except NoResultFound as e:
1033
1039
  raise HTTPException(status_code=404, detail=f"Agent with id={agent_id} not found for user_id={actor.id}.")
@@ -1239,6 +1245,12 @@ async def send_message(
1239
1245
  )
1240
1246
  job_status = result.stop_reason.stop_reason.run_status
1241
1247
  return result
1248
+ except PendingApprovalError as e:
1249
+ job_update_metadata = {"error": str(e)}
1250
+ job_status = JobStatus.failed
1251
+ raise HTTPException(
1252
+ status_code=409, detail={"code": "PENDING_APPROVAL", "message": str(e), "pending_request_id": e.pending_request_id}
1253
+ )
1242
1254
  except Exception as e:
1243
1255
  job_update_metadata = {"error": str(e)}
1244
1256
  job_status = JobStatus.failed
@@ -1437,6 +1449,13 @@ async def send_message_streaming(
1437
1449
  if settings.track_agent_run:
1438
1450
  job_status = JobStatus.running
1439
1451
  return result
1452
+ except PendingApprovalError as e:
1453
+ if settings.track_agent_run:
1454
+ job_update_metadata = {"error": str(e)}
1455
+ job_status = JobStatus.failed
1456
+ raise HTTPException(
1457
+ status_code=409, detail={"code": "PENDING_APPROVAL", "message": str(e), "pending_request_id": e.pending_request_id}
1458
+ )
1440
1459
  except Exception as e:
1441
1460
  if settings.track_agent_run:
1442
1461
  job_update_metadata = {"error": str(e)}
@@ -1498,6 +1517,42 @@ async def cancel_agent_run(
1498
1517
  return results
1499
1518
 
1500
1519
 
1520
+ @router.post("/messages/search", response_model=List[MessageSearchResult], operation_id="search_messages")
1521
+ async def search_messages(
1522
+ request: MessageSearchRequest = Body(...),
1523
+ server: SyncServer = Depends(get_letta_server),
1524
+ actor_id: str | None = Header(None, alias="user_id"),
1525
+ ):
1526
+ """
1527
+ Search messages across the entire organization with optional project and template filtering. Returns messages with FTS/vector ranks and total RRF score.
1528
+
1529
+ This is a cloud-only feature.
1530
+ """
1531
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
1532
+
1533
+ # get embedding config from the default agent if needed
1534
+ # check if any agents exist in the org
1535
+ agent_count = await server.agent_manager.size_async(actor=actor)
1536
+ if agent_count == 0:
1537
+ raise HTTPException(status_code=400, detail="No agents found in organization to derive embedding configuration from")
1538
+
1539
+ try:
1540
+ results = await server.message_manager.search_messages_org_async(
1541
+ actor=actor,
1542
+ query_text=request.query,
1543
+ search_mode=request.search_mode,
1544
+ roles=request.roles,
1545
+ project_id=request.project_id,
1546
+ template_id=request.template_id,
1547
+ limit=request.limit,
1548
+ start_date=request.start_date,
1549
+ end_date=request.end_date,
1550
+ )
1551
+ return results
1552
+ except ValueError as e:
1553
+ raise HTTPException(status_code=400, detail=str(e))
1554
+
1555
+
1501
1556
  async def _process_message_background(
1502
1557
  run_id: str,
1503
1558
  server: SyncServer,
@@ -1590,6 +1645,14 @@ async def _process_message_background(
1590
1645
  )
1591
1646
  await server.job_manager.update_job_by_id_async(job_id=run_id, job_update=job_update, actor=actor)
1592
1647
 
1648
+ except PendingApprovalError as e:
1649
+ # Update job status to failed with specific error info
1650
+ job_update = JobUpdate(
1651
+ status=JobStatus.failed,
1652
+ completed_at=datetime.now(timezone.utc),
1653
+ metadata={"error": str(e), "error_code": "PENDING_APPROVAL", "pending_request_id": e.pending_request_id},
1654
+ )
1655
+ await server.job_manager.update_job_by_id_async(job_id=run_id, job_update=job_update, actor=actor)
1593
1656
  except Exception as e:
1594
1657
  # Update job status to failed
1595
1658
  job_update = JobUpdate(
@@ -1640,7 +1703,7 @@ async def send_message_async(
1640
1703
  run = await server.job_manager.create_job_async(pydantic_job=run, actor=actor)
1641
1704
 
1642
1705
  # Create asyncio task for background processing
1643
- asyncio.create_task(
1706
+ task = asyncio.create_task(
1644
1707
  _process_message_background(
1645
1708
  run_id=run.id,
1646
1709
  server=server,
@@ -1655,6 +1718,38 @@ async def send_message_async(
1655
1718
  )
1656
1719
  )
1657
1720
 
1721
+ def handle_task_completion(t):
1722
+ try:
1723
+ t.result()
1724
+ except asyncio.CancelledError:
1725
+ logger.error(f"Background task for run {run.id} was cancelled")
1726
+ asyncio.create_task(
1727
+ server.job_manager.update_job_by_id_async(
1728
+ job_id=run.id,
1729
+ job_update=JobUpdate(
1730
+ status=JobStatus.failed,
1731
+ completed_at=datetime.now(timezone.utc),
1732
+ metadata={"error": "Task was cancelled"},
1733
+ ),
1734
+ actor=actor,
1735
+ )
1736
+ )
1737
+ except Exception as e:
1738
+ logger.error(f"Unhandled exception in background task for run {run.id}: {e}")
1739
+ asyncio.create_task(
1740
+ server.job_manager.update_job_by_id_async(
1741
+ job_id=run.id,
1742
+ job_update=JobUpdate(
1743
+ status=JobStatus.failed,
1744
+ completed_at=datetime.now(timezone.utc),
1745
+ metadata={"error": str(e)},
1746
+ ),
1747
+ actor=actor,
1748
+ )
1749
+ )
1750
+
1751
+ task.add_done_callback(handle_task_completion)
1752
+
1658
1753
  return run
1659
1754
 
1660
1755
 
@@ -68,6 +68,11 @@ async def list_blocks(
68
68
  "If provided, returns blocks that have exactly this number of connected agents."
69
69
  ),
70
70
  ),
71
+ show_hidden_blocks: bool | None = Query(
72
+ False,
73
+ include_in_schema=False,
74
+ description="If set to True, include blocks marked as hidden in the results.",
75
+ ),
71
76
  server: SyncServer = Depends(get_letta_server),
72
77
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
73
78
  ):
@@ -89,6 +94,7 @@ async def list_blocks(
89
94
  connected_to_agents_count_eq=connected_to_agents_count_eq,
90
95
  limit=limit,
91
96
  after=after,
97
+ show_hidden_blocks=show_hidden_blocks,
92
98
  )
93
99
 
94
100
 
@@ -15,6 +15,7 @@ from letta.helpers.pinecone_utils import (
15
15
  delete_source_records_from_pinecone_index,
16
16
  should_use_pinecone,
17
17
  )
18
+ from letta.helpers.tpuf_client import should_use_tpuf
18
19
  from letta.log import get_logger
19
20
  from letta.otel.tracing import trace_method
20
21
  from letta.schemas.agent import AgentState
@@ -191,7 +192,13 @@ async def delete_folder(
191
192
  files = await server.file_manager.list_files(folder_id, actor)
192
193
  file_ids = [f.id for f in files]
193
194
 
194
- if should_use_pinecone():
195
+ if should_use_tpuf():
196
+ logger.info(f"Deleting folder {folder_id} from Turbopuffer")
197
+ from letta.helpers.tpuf_client import TurbopufferClient
198
+
199
+ tpuf_client = TurbopufferClient()
200
+ await tpuf_client.delete_source_passages(source_id=folder_id, organization_id=actor.organization_id)
201
+ elif should_use_pinecone():
195
202
  logger.info(f"Deleting folder {folder_id} from pinecone index")
196
203
  await delete_source_records_from_pinecone_index(source_id=folder_id, actor=actor)
197
204
 
@@ -450,7 +457,13 @@ async def delete_file_from_folder(
450
457
 
451
458
  await server.remove_file_from_context_windows(source_id=folder_id, file_id=deleted_file.id, actor=actor)
452
459
 
453
- if should_use_pinecone():
460
+ if should_use_tpuf():
461
+ logger.info(f"Deleting file {file_id} from Turbopuffer")
462
+ from letta.helpers.tpuf_client import TurbopufferClient
463
+
464
+ tpuf_client = TurbopufferClient()
465
+ await tpuf_client.delete_file_passages(source_id=folder_id, file_id=file_id, organization_id=actor.organization_id)
466
+ elif should_use_pinecone():
454
467
  logger.info(f"Deleting file {file_id} from pinecone index")
455
468
  await delete_file_records_from_pinecone_index(file_id=file_id, actor=actor)
456
469
 
@@ -496,10 +509,15 @@ async def load_file_to_source_cloud(
496
509
  else:
497
510
  file_parser = MarkitdownFileParser()
498
511
 
499
- using_pinecone = should_use_pinecone()
500
- if using_pinecone:
512
+ # determine which embedder to use - turbopuffer takes precedence
513
+ if should_use_tpuf():
514
+ from letta.services.file_processor.embedder.turbopuffer_embedder import TurbopufferEmbedder
515
+
516
+ embedder = TurbopufferEmbedder(embedding_config=embedding_config)
517
+ elif should_use_pinecone():
501
518
  embedder = PineconeEmbedder(embedding_config=embedding_config)
502
519
  else:
503
520
  embedder = OpenAIEmbedder(embedding_config=embedding_config)
504
- file_processor = FileProcessor(file_parser=file_parser, embedder=embedder, actor=actor, using_pinecone=using_pinecone)
521
+
522
+ file_processor = FileProcessor(file_parser=file_parser, embedder=embedder, actor=actor)
505
523
  await file_processor.process(agent_states=agent_states, source_id=source_id, content=content, file_metadata=file_metadata)
@@ -25,6 +25,11 @@ async def list_groups(
25
25
  after: Optional[str] = Query(None, description="Cursor for pagination"),
26
26
  limit: Optional[int] = Query(None, description="Limit for pagination"),
27
27
  project_id: Optional[str] = Query(None, description="Search groups by project id"),
28
+ show_hidden_groups: bool | None = Query(
29
+ False,
30
+ include_in_schema=False,
31
+ description="If set to True, include groups marked as hidden in the results.",
32
+ ),
28
33
  ):
29
34
  """
30
35
  Fetch all multi-agent groups matching query.
@@ -37,6 +42,7 @@ async def list_groups(
37
42
  before=before,
38
43
  after=after,
39
44
  limit=limit,
45
+ show_hidden_groups=show_hidden_groups,
40
46
  )
41
47
 
42
48
 
@@ -1,6 +1,7 @@
1
- from typing import Optional
1
+ from typing import List, Optional
2
2
 
3
- from fastapi import APIRouter, Body, Depends, Header, HTTPException
3
+ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
4
+ from pydantic import BaseModel
4
5
 
5
6
  from letta.schemas.agent import AgentState, InternalTemplateAgentCreate
6
7
  from letta.schemas.block import Block, InternalTemplateBlockCreate
@@ -16,9 +17,6 @@ async def create_group(
16
17
  group: InternalTemplateGroupCreate = Body(...),
17
18
  server: "SyncServer" = Depends(get_letta_server),
18
19
  actor_id: Optional[str] = Header(None, alias="user_id"),
19
- x_project: Optional[str] = Header(
20
- None, alias="X-Project", description="The project slug to associate with the group (cloud only)."
21
- ), # Only handled by next js middleware
22
20
  ):
23
21
  """
24
22
  Create a new multi-agent group with the specified configuration.
@@ -35,9 +33,6 @@ async def create_agent(
35
33
  agent: InternalTemplateAgentCreate = Body(...),
36
34
  server: "SyncServer" = Depends(get_letta_server),
37
35
  actor_id: Optional[str] = Header(None, alias="user_id"),
38
- x_project: Optional[str] = Header(
39
- None, alias="X-Project", description="The project slug to associate with the agent (cloud only)."
40
- ), # Only handled by next js middleware
41
36
  ):
42
37
  """
43
38
  Create a new agent with template-related fields.
@@ -54,15 +49,226 @@ async def create_block(
54
49
  block: InternalTemplateBlockCreate = Body(...),
55
50
  server: "SyncServer" = Depends(get_letta_server),
56
51
  actor_id: Optional[str] = Header(None, alias="user_id"),
57
- x_project: Optional[str] = Header(
58
- None, alias="X-Project", description="The project slug to associate with the block (cloud only)."
59
- ), # Only handled by next js middleware
60
52
  ):
61
53
  """
62
54
  Create a new block with template-related fields.
63
55
  """
64
56
  try:
65
57
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
66
- return await server.block_manager.create_or_update_block_async(block, actor=actor)
58
+ block_obj = Block(**block.model_dump())
59
+ return await server.block_manager.create_or_update_block_async(block_obj, actor=actor)
60
+ except Exception as e:
61
+ raise HTTPException(status_code=500, detail=str(e))
62
+
63
+
64
+ class DeploymentEntity(BaseModel):
65
+ """A deployment entity."""
66
+
67
+ id: str
68
+ type: str
69
+ name: Optional[str] = None
70
+ description: Optional[str] = None
71
+
72
+
73
+ class ListDeploymentEntitiesResponse(BaseModel):
74
+ """Response model for listing deployment entities."""
75
+
76
+ entities: List[DeploymentEntity] = []
77
+ total_count: int
78
+ deployment_id: str
79
+ message: str
80
+
81
+
82
+ class DeleteDeploymentResponse(BaseModel):
83
+ """Response model for delete deployment operation."""
84
+
85
+ deleted_blocks: List[str] = []
86
+ deleted_agents: List[str] = []
87
+ deleted_groups: List[str] = []
88
+ message: str
89
+
90
+
91
+ @router.get("/deployment/{deployment_id}", response_model=ListDeploymentEntitiesResponse, operation_id="list_deployment_entities")
92
+ async def list_deployment_entities(
93
+ deployment_id: str,
94
+ server: "SyncServer" = Depends(get_letta_server),
95
+ actor_id: Optional[str] = Header(None, alias="user_id"),
96
+ entity_types: Optional[List[str]] = Query(None, description="Filter by entity types (block, agent, group)"),
97
+ ):
98
+ """
99
+ List all entities (blocks, agents, groups) with the specified deployment_id.
100
+ Optionally filter by entity types.
101
+ """
102
+ try:
103
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
104
+
105
+ entities = []
106
+
107
+ # Parse entity_types filter - support both array and comma-separated string
108
+ allowed_types = {"block", "agent", "group"}
109
+ if entity_types is None:
110
+ # If no filter specified, include all types
111
+ types_to_include = allowed_types
112
+ else:
113
+ # Handle comma-separated strings in a single item
114
+ if len(entity_types) == 1 and "," in entity_types[0]:
115
+ entity_types = [t.strip() for t in entity_types[0].split(",")]
116
+
117
+ # Validate and filter types
118
+ types_to_include = {t.lower() for t in entity_types if t.lower() in allowed_types}
119
+ if not types_to_include:
120
+ types_to_include = allowed_types # Default to all if invalid types provided
121
+
122
+ # Query blocks if requested
123
+ if "block" in types_to_include:
124
+ from sqlalchemy import select
125
+
126
+ from letta.orm.block import Block as BlockModel
127
+ from letta.server.db import db_registry
128
+
129
+ async with db_registry.async_session() as session:
130
+ block_query = select(BlockModel).where(
131
+ BlockModel.deployment_id == deployment_id, BlockModel.organization_id == actor.organization_id
132
+ )
133
+ result = await session.execute(block_query)
134
+ blocks = result.scalars().all()
135
+
136
+ for block in blocks:
137
+ entities.append(
138
+ DeploymentEntity(
139
+ id=block.id,
140
+ type="block",
141
+ name=getattr(block, "template_name", None) or getattr(block, "label", None),
142
+ description=block.description,
143
+ )
144
+ )
145
+
146
+ # Query agents if requested
147
+ if "agent" in types_to_include:
148
+ from letta.orm.agent import Agent as AgentModel
149
+
150
+ async with db_registry.async_session() as session:
151
+ agent_query = select(AgentModel).where(
152
+ AgentModel.deployment_id == deployment_id, AgentModel.organization_id == actor.organization_id
153
+ )
154
+ result = await session.execute(agent_query)
155
+ agents = result.scalars().all()
156
+
157
+ for agent in agents:
158
+ entities.append(DeploymentEntity(id=agent.id, type="agent", name=agent.name, description=agent.description))
159
+
160
+ # Query groups if requested
161
+ if "group" in types_to_include:
162
+ from letta.orm.group import Group as GroupModel
163
+
164
+ async with db_registry.async_session() as session:
165
+ group_query = select(GroupModel).where(
166
+ GroupModel.deployment_id == deployment_id, GroupModel.organization_id == actor.organization_id
167
+ )
168
+ result = await session.execute(group_query)
169
+ groups = result.scalars().all()
170
+
171
+ for group in groups:
172
+ entities.append(
173
+ DeploymentEntity(
174
+ id=group.id,
175
+ type="group",
176
+ name=None, # Groups don't have a name field
177
+ description=group.description,
178
+ )
179
+ )
180
+
181
+ message = f"Found {len(entities)} entities for deployment {deployment_id}"
182
+ if entity_types:
183
+ message += f" (filtered by types: {', '.join(types_to_include)})"
184
+
185
+ return ListDeploymentEntitiesResponse(entities=entities, total_count=len(entities), deployment_id=deployment_id, message=message)
186
+ except Exception as e:
187
+ raise HTTPException(status_code=500, detail=str(e))
188
+
189
+
190
+ @router.delete("/deployment/{deployment_id}", response_model=DeleteDeploymentResponse, operation_id="delete_deployment")
191
+ async def delete_deployment(
192
+ deployment_id: str,
193
+ server: "SyncServer" = Depends(get_letta_server),
194
+ actor_id: Optional[str] = Header(None, alias="user_id"),
195
+ ):
196
+ """
197
+ Delete all entities (blocks, agents, groups) with the specified deployment_id.
198
+ Deletion order: blocks -> agents -> groups to maintain referential integrity.
199
+ """
200
+ try:
201
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
202
+
203
+ deleted_blocks = []
204
+ deleted_agents = []
205
+ deleted_groups = []
206
+
207
+ # First delete blocks
208
+ from sqlalchemy import select
209
+
210
+ from letta.orm.block import Block as BlockModel
211
+ from letta.server.db import db_registry
212
+
213
+ async with db_registry.async_session() as session:
214
+ # Get all blocks with the deployment_id
215
+ block_query = select(BlockModel).where(
216
+ BlockModel.deployment_id == deployment_id, BlockModel.organization_id == actor.organization_id
217
+ )
218
+ result = await session.execute(block_query)
219
+ blocks = result.scalars().all()
220
+
221
+ for block in blocks:
222
+ try:
223
+ await server.block_manager.delete_block_async(block.id, actor)
224
+ deleted_blocks.append(block.id)
225
+ except Exception as e:
226
+ # Continue deleting other blocks even if one fails
227
+ print(f"Failed to delete block {block.id}: {e}")
228
+
229
+ # Then delete agents
230
+ from letta.orm.agent import Agent as AgentModel
231
+
232
+ async with db_registry.async_session() as session:
233
+ # Get all agents with the deployment_id
234
+ agent_query = select(AgentModel).where(
235
+ AgentModel.deployment_id == deployment_id, AgentModel.organization_id == actor.organization_id
236
+ )
237
+ result = await session.execute(agent_query)
238
+ agents = result.scalars().all()
239
+
240
+ for agent in agents:
241
+ try:
242
+ await server.agent_manager.delete_agent_async(agent.id, actor)
243
+ deleted_agents.append(agent.id)
244
+ except Exception as e:
245
+ # Continue deleting other agents even if one fails
246
+ print(f"Failed to delete agent {agent.id}: {e}")
247
+
248
+ # Finally delete groups
249
+ from letta.orm.group import Group as GroupModel
250
+
251
+ async with db_registry.async_session() as session:
252
+ # Get all groups with the deployment_id
253
+ group_query = select(GroupModel).where(
254
+ GroupModel.deployment_id == deployment_id, GroupModel.organization_id == actor.organization_id
255
+ )
256
+ result = await session.execute(group_query)
257
+ groups = result.scalars().all()
258
+
259
+ for group in groups:
260
+ try:
261
+ await server.group_manager.delete_group_async(group.id, actor)
262
+ deleted_groups.append(group.id)
263
+ except Exception as e:
264
+ # Continue deleting other groups even if one fails
265
+ print(f"Failed to delete group {group.id}: {e}")
266
+
267
+ total_deleted = len(deleted_blocks) + len(deleted_agents) + len(deleted_groups)
268
+ message = f"Successfully deleted {total_deleted} entities from deployment {deployment_id}"
269
+
270
+ return DeleteDeploymentResponse(
271
+ deleted_blocks=deleted_blocks, deleted_agents=deleted_agents, deleted_groups=deleted_groups, message=message
272
+ )
67
273
  except Exception as e:
68
274
  raise HTTPException(status_code=500, detail=str(e))
@@ -1,4 +1,4 @@
1
- from typing import List, Optional
1
+ from typing import List, Literal, Optional
2
2
 
3
3
  from fastapi import APIRouter, Body, Depends, Header, Query
4
4
  from fastapi.exceptions import HTTPException
@@ -25,9 +25,9 @@ logger = get_logger(__name__)
25
25
  @router.post(
26
26
  "/batches",
27
27
  response_model=BatchJob,
28
- operation_id="create_messages_batch",
28
+ operation_id="create_batch_run",
29
29
  )
30
- async def create_messages_batch(
30
+ async def create_batch_run(
31
31
  request: Request,
32
32
  payload: CreateBatch = Body(..., description="Messages and config for all agents"),
33
33
  server: SyncServer = Depends(get_letta_server),
@@ -127,25 +127,21 @@ async def list_batch_runs(
127
127
  )
128
128
  async def list_batch_messages(
129
129
  batch_id: str,
130
- limit: int = Query(100, description="Maximum number of messages to return"),
131
- cursor: Optional[str] = Query(
132
- None, description="Message ID to use as pagination cursor (get messages before/after this ID) depending on sort_descending."
130
+ before: Optional[str] = Query(
131
+ None, description="Message ID cursor for pagination. Returns messages that come before this message ID in the specified sort order"
132
+ ),
133
+ after: Optional[str] = Query(
134
+ None, description="Message ID cursor for pagination. Returns messages that come after this message ID in the specified sort order"
135
+ ),
136
+ limit: Optional[int] = Query(100, description="Maximum number of messages to return"),
137
+ order: Literal["asc", "desc"] = Query(
138
+ "desc", description="Sort order for messages by creation time. 'asc' for oldest first, 'desc' for newest first"
133
139
  ),
134
140
  agent_id: Optional[str] = Query(None, description="Filter messages by agent ID"),
135
- sort_descending: bool = Query(True, description="Sort messages by creation time (true=newest first)"),
136
141
  actor_id: Optional[str] = Header(None, alias="user_id"),
137
142
  server: SyncServer = Depends(get_letta_server),
138
143
  ):
139
- """
140
- Get messages for a specific batch job.
141
-
142
- Returns messages associated with the batch in chronological order.
143
-
144
- Pagination:
145
- - For the first page, omit the cursor parameter
146
- - For subsequent pages, use the ID of the last message from the previous response as the cursor
147
- - Results will include messages before/after the cursor based on sort_descending
148
- """
144
+ """Get response messages for a specific batch job."""
149
145
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
150
146
 
151
147
  # First, verify the batch job exists and the user has access to it
@@ -156,9 +152,8 @@ async def list_batch_messages(
156
152
  raise HTTPException(status_code=404, detail="Batch not found")
157
153
 
158
154
  # Get messages directly using our efficient method
159
- # We'll need to update the underlying implementation to use message_id as cursor
160
155
  messages = await server.batch_manager.get_messages_for_letta_batch_async(
161
- letta_batch_job_id=batch_id, limit=limit, actor=actor, agent_id=agent_id, sort_descending=sort_descending, cursor=cursor
156
+ letta_batch_job_id=batch_id, limit=limit, actor=actor, agent_id=agent_id, ascending=(order == "asc"), before=before, after=after
162
157
  )
163
158
 
164
159
  return LettaBatchMessages(messages=messages)