letta-nightly 0.11.7.dev20250916104104__py3-none-any.whl → 0.11.7.dev20250917104122__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 (44) 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 +1 -1
  5. letta/agents/letta_agent.py +1 -4
  6. letta/agents/letta_agent_v2.py +2 -1
  7. letta/agents/voice_agent.py +1 -1
  8. letta/helpers/converters.py +8 -2
  9. letta/helpers/crypto_utils.py +144 -0
  10. letta/llm_api/llm_api_tools.py +0 -1
  11. letta/llm_api/llm_client_base.py +0 -2
  12. letta/orm/__init__.py +1 -0
  13. letta/orm/agent.py +5 -1
  14. letta/orm/job.py +3 -1
  15. letta/orm/mcp_oauth.py +6 -0
  16. letta/orm/mcp_server.py +7 -1
  17. letta/orm/sqlalchemy_base.py +2 -1
  18. letta/schemas/agent.py +10 -7
  19. letta/schemas/job.py +10 -0
  20. letta/schemas/mcp.py +146 -6
  21. letta/schemas/provider_trace.py +0 -2
  22. letta/schemas/run.py +2 -0
  23. letta/schemas/secret.py +378 -0
  24. letta/serialize_schemas/marshmallow_agent.py +4 -0
  25. letta/server/rest_api/routers/v1/__init__.py +2 -0
  26. letta/server/rest_api/routers/v1/agents.py +9 -4
  27. letta/server/rest_api/routers/v1/archives.py +113 -0
  28. letta/server/rest_api/routers/v1/jobs.py +7 -2
  29. letta/server/rest_api/routers/v1/runs.py +9 -1
  30. letta/server/rest_api/routers/v1/tools.py +7 -26
  31. letta/services/agent_manager.py +17 -9
  32. letta/services/agent_serialization_manager.py +11 -3
  33. letta/services/archive_manager.py +73 -0
  34. letta/services/helpers/agent_manager_helper.py +6 -1
  35. letta/services/job_manager.py +18 -2
  36. letta/services/mcp_manager.py +198 -82
  37. letta/services/telemetry_manager.py +2 -0
  38. letta/services/tool_executor/composio_tool_executor.py +1 -1
  39. letta/services/tool_sandbox/base.py +2 -3
  40. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/METADATA +5 -2
  41. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/RECORD +44 -41
  42. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/WHEEL +0 -0
  43. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/entry_points.txt +0 -0
  44. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/licenses/LICENSE +0 -0
@@ -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
 
@@ -0,0 +1,113 @@
1
+ from typing import List, Literal, Optional
2
+
3
+ from fastapi import APIRouter, Body, Depends, HTTPException, Query
4
+ from pydantic import BaseModel
5
+
6
+ from letta.orm.errors import NoResultFound
7
+ from letta.schemas.archive import Archive as PydanticArchive
8
+ from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server
9
+ from letta.server.server import SyncServer
10
+
11
+ router = APIRouter(prefix="/archives", tags=["archives"])
12
+
13
+
14
+ class ArchiveCreateRequest(BaseModel):
15
+ """Request model for creating an archive.
16
+
17
+ Intentionally excludes vector_db_provider. These are derived internally (vector DB provider from env).
18
+ """
19
+
20
+ name: str
21
+ description: Optional[str] = None
22
+
23
+
24
+ class ArchiveUpdateRequest(BaseModel):
25
+ """Request model for updating an archive (partial).
26
+
27
+ Supports updating only name and description.
28
+ """
29
+
30
+ name: Optional[str] = None
31
+ description: Optional[str] = None
32
+
33
+
34
+ @router.post("/", response_model=PydanticArchive, operation_id="create_archive")
35
+ async def create_archive(
36
+ archive: ArchiveCreateRequest = Body(...),
37
+ server: "SyncServer" = Depends(get_letta_server),
38
+ headers: HeaderParams = Depends(get_headers),
39
+ ):
40
+ """
41
+ Create a new archive.
42
+ """
43
+ try:
44
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
45
+ return await server.archive_manager.create_archive_async(
46
+ name=archive.name,
47
+ description=archive.description,
48
+ actor=actor,
49
+ )
50
+ except Exception as e:
51
+ raise HTTPException(status_code=500, detail=str(e))
52
+
53
+
54
+ @router.get("/", response_model=List[PydanticArchive], operation_id="list_archives")
55
+ async def list_archives(
56
+ before: Optional[str] = Query(
57
+ None,
58
+ description="Archive ID cursor for pagination. Returns archives that come before this archive ID in the specified sort order",
59
+ ),
60
+ after: Optional[str] = Query(
61
+ None,
62
+ description="Archive ID cursor for pagination. Returns archives that come after this archive ID in the specified sort order",
63
+ ),
64
+ limit: Optional[int] = Query(50, description="Maximum number of archives to return"),
65
+ order: Literal["asc", "desc"] = Query(
66
+ "desc", description="Sort order for archives by creation time. 'asc' for oldest first, 'desc' for newest first"
67
+ ),
68
+ name: Optional[str] = Query(None, description="Filter by archive name (exact match)"),
69
+ agent_id: Optional[str] = Query(None, description="Only archives attached to this agent ID"),
70
+ server: "SyncServer" = Depends(get_letta_server),
71
+ headers: HeaderParams = Depends(get_headers),
72
+ ):
73
+ """
74
+ Get a list of all archives for the current organization with optional filters and pagination.
75
+ """
76
+ try:
77
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
78
+ archives = await server.archive_manager.list_archives_async(
79
+ actor=actor,
80
+ before=before,
81
+ after=after,
82
+ limit=limit,
83
+ ascending=(order == "asc"),
84
+ name=name,
85
+ agent_id=agent_id,
86
+ )
87
+ return archives
88
+ except Exception as e:
89
+ raise HTTPException(status_code=500, detail=str(e))
90
+
91
+
92
+ @router.patch("/{archive_id}", response_model=PydanticArchive, operation_id="modify_archive")
93
+ async def modify_archive(
94
+ archive_id: str,
95
+ archive: ArchiveUpdateRequest = Body(...),
96
+ server: "SyncServer" = Depends(get_letta_server),
97
+ headers: HeaderParams = Depends(get_headers),
98
+ ):
99
+ """
100
+ Update an existing archive's name and/or description.
101
+ """
102
+ try:
103
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
104
+ return await server.archive_manager.update_archive_async(
105
+ archive_id=archive_id,
106
+ name=archive.name,
107
+ description=archive.description,
108
+ actor=actor,
109
+ )
110
+ except NoResultFound as e:
111
+ raise HTTPException(status_code=404, detail=str(e))
112
+ except Exception as e:
113
+ raise HTTPException(status_code=500, detail=str(e))
@@ -19,18 +19,23 @@ async def list_jobs(
19
19
  before: Optional[str] = Query(None, description="Cursor for pagination"),
20
20
  after: Optional[str] = Query(None, description="Cursor for pagination"),
21
21
  limit: Optional[int] = Query(50, description="Limit for pagination"),
22
+ active: bool = Query(False, description="Filter for active jobs."),
22
23
  ascending: bool = Query(True, description="Whether to sort jobs oldest to newest (True, default) or newest to oldest (False)"),
23
24
  headers: HeaderParams = Depends(get_headers),
24
25
  ):
25
26
  """
26
27
  List all jobs.
27
- TODO (cliandy): implementation for pagination
28
28
  """
29
29
  actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
30
30
 
31
+ statuses = None
32
+ if active:
33
+ statuses = [JobStatus.created, JobStatus.running]
34
+
31
35
  # TODO: add filtering by status
32
36
  return await server.job_manager.list_jobs_async(
33
37
  actor=actor,
38
+ statuses=statuses,
34
39
  source_id=source_id,
35
40
  before=before,
36
41
  after=after,
@@ -39,7 +44,7 @@ async def list_jobs(
39
44
  )
40
45
 
41
46
 
42
- @router.get("/active", response_model=List[Job], operation_id="list_active_jobs")
47
+ @router.get("/active", response_model=List[Job], operation_id="list_active_jobs", deprecated=True)
43
48
  async def list_active_jobs(
44
49
  server: "SyncServer" = Depends(get_letta_server),
45
50
  headers: HeaderParams = Depends(get_headers),
@@ -10,6 +10,7 @@ from letta.orm.errors import NoResultFound
10
10
  from letta.schemas.enums import JobStatus, JobType
11
11
  from letta.schemas.letta_message import LettaMessageUnion
12
12
  from letta.schemas.letta_request import RetrieveStreamRequest
13
+ from letta.schemas.letta_stop_reason import StopReasonType
13
14
  from letta.schemas.openai.chat_completion_response import UsageStatistics
14
15
  from letta.schemas.run import Run
15
16
  from letta.schemas.step import Step
@@ -31,9 +32,11 @@ def list_runs(
31
32
  server: "SyncServer" = Depends(get_letta_server),
32
33
  agent_ids: Optional[List[str]] = Query(None, description="The unique identifier of the agent associated with the run."),
33
34
  background: Optional[bool] = Query(None, description="If True, filters for runs that were created in background mode."),
35
+ stop_reason: Optional[StopReasonType] = Query(None, description="Filter runs by stop reason."),
34
36
  after: Optional[str] = Query(None, description="Cursor for pagination"),
35
37
  before: Optional[str] = Query(None, description="Cursor for pagination"),
36
38
  limit: Optional[int] = Query(50, description="Maximum number of runs to return"),
39
+ active: bool = Query(False, description="Filter for active runs."),
37
40
  ascending: bool = Query(
38
41
  False,
39
42
  description="Whether to sort agents oldest to newest (True) or newest to oldest (False, default)",
@@ -44,16 +47,21 @@ def list_runs(
44
47
  List all runs.
45
48
  """
46
49
  actor = server.user_manager.get_user_or_default(user_id=headers.actor_id)
50
+ statuses = None
51
+ if active:
52
+ statuses = [JobStatus.created, JobStatus.running]
47
53
 
48
54
  runs = [
49
55
  Run.from_job(job)
50
56
  for job in server.job_manager.list_jobs(
51
57
  actor=actor,
58
+ statuses=statuses,
52
59
  job_type=JobType.RUN,
53
60
  limit=limit,
54
61
  before=before,
55
62
  after=after,
56
63
  ascending=False,
64
+ stop_reason=stop_reason,
57
65
  )
58
66
  ]
59
67
  if agent_ids:
@@ -63,7 +71,7 @@ def list_runs(
63
71
  return runs
64
72
 
65
73
 
66
- @router.get("/active", response_model=List[Run], operation_id="list_active_runs")
74
+ @router.get("/active", response_model=List[Run], operation_id="list_active_runs", deprecated=True)
67
75
  def list_active_runs(
68
76
  server: "SyncServer" = Depends(get_letta_server),
69
77
  agent_ids: Optional[List[str]] = Query(None, description="The unique identifier of the agent associated with the run."),
@@ -728,35 +728,16 @@ async def add_mcp_server_to_config(
728
728
  return await server.add_mcp_server_to_config(server_config=request, allow_upsert=True)
729
729
  else:
730
730
  # log to DB
731
- from letta.schemas.mcp import MCPServer
732
-
733
- if isinstance(request, StdioServerConfig):
734
- mapped_request = MCPServer(server_name=request.server_name, server_type=request.type, stdio_config=request)
735
- # don't allow stdio servers
736
- if tool_settings.mcp_disable_stdio: # protected server
737
- raise HTTPException(
738
- status_code=400,
739
- detail="stdio is not supported in the current environment, please use a self-hosted Letta server in order to add a stdio MCP server",
740
- )
741
- elif isinstance(request, SSEServerConfig):
742
- mapped_request = MCPServer(
743
- server_name=request.server_name,
744
- server_type=request.type,
745
- server_url=request.server_url,
746
- token=request.resolve_token(),
747
- custom_headers=request.custom_headers,
748
- )
749
- elif isinstance(request, StreamableHTTPServerConfig):
750
- mapped_request = MCPServer(
751
- server_name=request.server_name,
752
- server_type=request.type,
753
- server_url=request.server_url,
754
- token=request.resolve_token(),
755
- custom_headers=request.custom_headers,
731
+ # Check if stdio servers are disabled
732
+ if isinstance(request, StdioServerConfig) and tool_settings.mcp_disable_stdio:
733
+ raise HTTPException(
734
+ status_code=400,
735
+ detail="stdio is not supported in the current environment, please use a self-hosted Letta server in order to add a stdio MCP server",
756
736
  )
757
737
 
758
738
  # Create MCP server and optimistically sync tools
759
- await server.mcp_manager.create_mcp_server_with_tools(mapped_request, actor=actor)
739
+ # The mcp_manager will handle encryption of sensitive fields
740
+ await server.mcp_manager.create_mcp_server_from_config_with_tools(request, actor=actor)
760
741
 
761
742
  # TODO: don't do this in the future (just return MCPServer)
762
743
  all_servers = await server.mcp_manager.list_mcp_servers(actor=actor)
@@ -455,7 +455,8 @@ class AgentManager:
455
455
  [{"agent_id": aid, "identity_id": iid} for iid in identity_ids],
456
456
  )
457
457
 
458
- if agent_create.tool_exec_environment_variables:
458
+ agent_secrets = agent_create.secrets or agent_create.tool_exec_environment_variables
459
+ if agent_secrets:
459
460
  env_rows = [
460
461
  {
461
462
  "agent_id": aid,
@@ -463,7 +464,7 @@ class AgentManager:
463
464
  "value": val,
464
465
  "organization_id": actor.organization_id,
465
466
  }
466
- for key, val in agent_create.tool_exec_environment_variables.items()
467
+ for key, val in agent_secrets.items()
467
468
  ]
468
469
  session.execute(insert(AgentEnvironmentVariable).values(env_rows))
469
470
 
@@ -674,7 +675,8 @@ class AgentManager:
674
675
  )
675
676
 
676
677
  env_rows = []
677
- if agent_create.tool_exec_environment_variables:
678
+ agent_secrets = agent_create.secrets or agent_create.tool_exec_environment_variables
679
+ if agent_secrets:
678
680
  env_rows = [
679
681
  {
680
682
  "agent_id": aid,
@@ -682,7 +684,7 @@ class AgentManager:
682
684
  "value": val,
683
685
  "organization_id": actor.organization_id,
684
686
  }
685
- for key, val in agent_create.tool_exec_environment_variables.items()
687
+ for key, val in agent_secrets.items()
686
688
  ]
687
689
  result = await session.execute(insert(AgentEnvironmentVariable).values(env_rows).returning(AgentEnvironmentVariable.id))
688
690
  env_rows = [{**row, "id": env_var_id} for row, env_var_id in zip(env_rows, result.scalars().all())]
@@ -701,8 +703,9 @@ class AgentManager:
701
703
 
702
704
  result = await new_agent.to_pydantic_async(include_relationships=include_relationships)
703
705
 
704
- if agent_create.tool_exec_environment_variables and env_rows:
706
+ if agent_secrets and env_rows:
705
707
  result.tool_exec_environment_variables = [AgentEnvironmentVariable(**row) for row in env_rows]
708
+ result.secrets = [AgentEnvironmentVariable(**row) for row in env_rows]
706
709
 
707
710
  # initial message sequence (skip if _init_with_no_messages is True)
708
711
  if not _init_with_no_messages:
@@ -894,7 +897,8 @@ class AgentManager:
894
897
  )
895
898
  session.expire(agent, ["tags"])
896
899
 
897
- if agent_update.tool_exec_environment_variables is not None:
900
+ agent_secrets = agent_update.secrets or agent_update.tool_exec_environment_variables
901
+ if agent_secrets is not None:
898
902
  session.execute(delete(AgentEnvironmentVariable).where(AgentEnvironmentVariable.agent_id == aid))
899
903
  env_rows = [
900
904
  {
@@ -903,7 +907,7 @@ class AgentManager:
903
907
  "value": v,
904
908
  "organization_id": agent.organization_id,
905
909
  }
906
- for k, v in agent_update.tool_exec_environment_variables.items()
910
+ for k, v in agent_secrets.items()
907
911
  ]
908
912
  if env_rows:
909
913
  self._bulk_insert_pivot(session, AgentEnvironmentVariable.__table__, env_rows)
@@ -1019,7 +1023,8 @@ class AgentManager:
1019
1023
  )
1020
1024
  session.expire(agent, ["tags"])
1021
1025
 
1022
- if agent_update.tool_exec_environment_variables is not None:
1026
+ agent_secrets = agent_update.secrets or agent_update.tool_exec_environment_variables
1027
+ if agent_secrets is not None:
1023
1028
  await session.execute(delete(AgentEnvironmentVariable).where(AgentEnvironmentVariable.agent_id == aid))
1024
1029
  env_rows = [
1025
1030
  {
@@ -1028,7 +1033,7 @@ class AgentManager:
1028
1033
  "value": v,
1029
1034
  "organization_id": agent.organization_id,
1030
1035
  }
1031
- for k, v in agent_update.tool_exec_environment_variables.items()
1036
+ for k, v in agent_secrets.items()
1032
1037
  ]
1033
1038
  if env_rows:
1034
1039
  await self._bulk_insert_pivot_async(session, AgentEnvironmentVariable.__table__, env_rows)
@@ -1544,6 +1549,8 @@ class AgentManager:
1544
1549
  if env_vars:
1545
1550
  for var in agent.tool_exec_environment_variables:
1546
1551
  var.value = env_vars.get(var.key, "")
1552
+ for var in agent.secrets:
1553
+ var.value = env_vars.get(var.key, "")
1547
1554
 
1548
1555
  agent = agent.create(session, actor=actor)
1549
1556
 
@@ -1627,6 +1634,7 @@ class AgentManager:
1627
1634
  # Remove stale variables
1628
1635
  stale_keys = set(existing_vars) - set(env_vars)
1629
1636
  agent.tool_exec_environment_variables = [var for var in updated_vars if var.key not in stale_keys]
1637
+ agent.secrets = [var for var in updated_vars if var.key not in stale_keys]
1630
1638
 
1631
1639
  # Update the agent in the database
1632
1640
  agent.update(session, actor=actor)
@@ -209,8 +209,10 @@ class AgentSerializationManager:
209
209
  agent_schema.id = agent_file_id
210
210
 
211
211
  # wipe the values of tool_exec_environment_variables (they contain secrets)
212
- if agent_schema.tool_exec_environment_variables:
213
- agent_schema.tool_exec_environment_variables = {key: "" for key in agent_schema.tool_exec_environment_variables}
212
+ agent_secrets = agent_schema.secrets or agent_schema.tool_exec_environment_variables
213
+ if agent_secrets:
214
+ agent_schema.tool_exec_environment_variables = {key: "" for key in agent_secrets}
215
+ agent_schema.secrets = {key: "" for key in agent_secrets}
214
216
 
215
217
  if agent_schema.messages:
216
218
  for message in agent_schema.messages:
@@ -655,10 +657,16 @@ class AgentSerializationManager:
655
657
  if agent_data.get("source_ids"):
656
658
  agent_data["source_ids"] = [file_to_db_ids[file_id] for file_id in agent_data["source_ids"]]
657
659
 
658
- if env_vars and agent_data.get("tool_exec_environment_variables"):
660
+ if env_vars and agent_data.get("secrets"):
659
661
  # update environment variable values from the provided env_vars dict
662
+ for key in agent_data["secrets"]:
663
+ agent_data["secrets"][key] = env_vars.get(key, "")
664
+ agent_data["tool_exec_environment_variables"][key] = env_vars.get(key, "")
665
+ elif env_vars and agent_data.get("tool_exec_environment_variables"):
666
+ # also handle tool_exec_environment_variables for backwards compatibility
660
667
  for key in agent_data["tool_exec_environment_variables"]:
661
668
  agent_data["tool_exec_environment_variables"][key] = env_vars.get(key, "")
669
+ agent_data["secrets"][key] = env_vars.get(key, "")
662
670
 
663
671
  # Override project_id if provided
664
672
  if project_id:
@@ -87,6 +87,79 @@ class ArchiveManager:
87
87
  )
88
88
  return archive.to_pydantic()
89
89
 
90
+ @enforce_types
91
+ @trace_method
92
+ async def update_archive_async(
93
+ self,
94
+ archive_id: str,
95
+ name: Optional[str] = None,
96
+ description: Optional[str] = None,
97
+ actor: PydanticUser = None,
98
+ ) -> PydanticArchive:
99
+ """Update archive name and/or description."""
100
+ async with db_registry.async_session() as session:
101
+ archive = await ArchiveModel.read_async(
102
+ db_session=session,
103
+ identifier=archive_id,
104
+ actor=actor,
105
+ check_is_deleted=True,
106
+ )
107
+
108
+ if name is not None:
109
+ archive.name = name
110
+ if description is not None:
111
+ archive.description = description
112
+
113
+ await archive.update_async(session, actor=actor)
114
+ return archive.to_pydantic()
115
+
116
+ @enforce_types
117
+ @trace_method
118
+ async def list_archives_async(
119
+ self,
120
+ *,
121
+ actor: PydanticUser,
122
+ before: Optional[str] = None,
123
+ after: Optional[str] = None,
124
+ limit: Optional[int] = 50,
125
+ ascending: bool = False,
126
+ name: Optional[str] = None,
127
+ agent_id: Optional[str] = None,
128
+ ) -> List[PydanticArchive]:
129
+ """List archives with pagination and optional filters.
130
+
131
+ Filters:
132
+ - name: exact match on name
133
+ - agent_id: only archives attached to given agent
134
+ """
135
+ filter_kwargs = {}
136
+ if name is not None:
137
+ filter_kwargs["name"] = name
138
+
139
+ join_model = None
140
+ join_conditions = None
141
+ if agent_id is not None:
142
+ join_model = ArchivesAgents
143
+ join_conditions = [
144
+ ArchivesAgents.archive_id == ArchiveModel.id,
145
+ ArchivesAgents.agent_id == agent_id,
146
+ ]
147
+
148
+ async with db_registry.async_session() as session:
149
+ archives = await ArchiveModel.list_async(
150
+ db_session=session,
151
+ before=before,
152
+ after=after,
153
+ limit=limit,
154
+ ascending=ascending,
155
+ actor=actor,
156
+ check_is_deleted=True,
157
+ join_model=join_model,
158
+ join_conditions=join_conditions,
159
+ **filter_kwargs,
160
+ )
161
+ return [a.to_pydantic() for a in archives]
162
+
90
163
  @enforce_types
91
164
  @trace_method
92
165
  def attach_agent_to_archive(
@@ -29,7 +29,6 @@ from letta.orm.errors import NoResultFound
29
29
  from letta.orm.identity import Identity
30
30
  from letta.orm.passage import ArchivalPassage, SourcePassage
31
31
  from letta.orm.sources_agents import SourcesAgents
32
- from letta.orm.sqlite_functions import adapt_array
33
32
  from letta.otel.tracing import trace_method
34
33
  from letta.prompts import gpt_system
35
34
  from letta.prompts.prompt_generator import PromptGenerator
@@ -921,6 +920,8 @@ async def build_passage_query(
921
920
  main_query = main_query.order_by(combined_query.c.embedding.cosine_distance(embedded_text).asc())
922
921
  else:
923
922
  # SQLite with custom vector type
923
+ from letta.orm.sqlite_functions import adapt_array
924
+
924
925
  query_embedding_binary = adapt_array(embedded_text)
925
926
  main_query = main_query.order_by(
926
927
  func.cosine_distance(combined_query.c.embedding, query_embedding_binary).asc(),
@@ -1054,6 +1055,8 @@ async def build_source_passage_query(
1054
1055
  query = query.order_by(SourcePassage.embedding.cosine_distance(embedded_text).asc())
1055
1056
  else:
1056
1057
  # SQLite with custom vector type
1058
+ from letta.orm.sqlite_functions import adapt_array
1059
+
1057
1060
  query_embedding_binary = adapt_array(embedded_text)
1058
1061
  query = query.order_by(
1059
1062
  func.cosine_distance(SourcePassage.embedding, query_embedding_binary).asc(),
@@ -1151,6 +1154,8 @@ async def build_agent_passage_query(
1151
1154
  query = query.order_by(ArchivalPassage.embedding.cosine_distance(embedded_text).asc())
1152
1155
  else:
1153
1156
  # SQLite with custom vector type
1157
+ from letta.orm.sqlite_functions import adapt_array
1158
+
1154
1159
  query_embedding_binary = adapt_array(embedded_text)
1155
1160
  query = query.order_by(
1156
1161
  func.cosine_distance(ArchivalPassage.embedding, query_embedding_binary).asc(),
@@ -18,6 +18,7 @@ from letta.otel.tracing import log_event, trace_method
18
18
  from letta.schemas.enums import JobStatus, JobType, MessageRole
19
19
  from letta.schemas.job import BatchJob as PydanticBatchJob, Job as PydanticJob, JobUpdate, LettaRequestConfig
20
20
  from letta.schemas.letta_message import LettaMessage
21
+ from letta.schemas.letta_stop_reason import StopReasonType
21
22
  from letta.schemas.message import Message as PydanticMessage
22
23
  from letta.schemas.run import Run as PydanticRun
23
24
  from letta.schemas.step import Step as PydanticStep
@@ -207,7 +208,12 @@ class JobManager:
207
208
  @enforce_types
208
209
  @trace_method
209
210
  async def safe_update_job_status_async(
210
- self, job_id: str, new_status: JobStatus, actor: PydanticUser, metadata: Optional[dict] = None
211
+ self,
212
+ job_id: str,
213
+ new_status: JobStatus,
214
+ actor: PydanticUser,
215
+ stop_reason: Optional[StopReasonType] = None,
216
+ metadata: Optional[dict] = None,
211
217
  ) -> bool:
212
218
  """
213
219
  Safely update job status with state transition guards.
@@ -217,7 +223,7 @@ class JobManager:
217
223
  True if update was successful, False if update was skipped due to invalid transition
218
224
  """
219
225
  try:
220
- job_update_builder = partial(JobUpdate, status=new_status)
226
+ job_update_builder = partial(JobUpdate, status=new_status, stop_reason=stop_reason)
221
227
 
222
228
  # If metadata is provided, merge it with existing metadata
223
229
  if metadata:
@@ -268,6 +274,7 @@ class JobManager:
268
274
  statuses: Optional[List[JobStatus]] = None,
269
275
  job_type: JobType = JobType.JOB,
270
276
  ascending: bool = True,
277
+ stop_reason: Optional[StopReasonType] = None,
271
278
  ) -> List[PydanticJob]:
272
279
  """List all jobs with optional pagination and status filter."""
273
280
  with db_registry.session() as session:
@@ -277,6 +284,10 @@ class JobManager:
277
284
  if statuses:
278
285
  filter_kwargs["status"] = statuses
279
286
 
287
+ # Add stop_reason filter if provided
288
+ if stop_reason is not None:
289
+ filter_kwargs["stop_reason"] = stop_reason
290
+
280
291
  jobs = JobModel.list(
281
292
  db_session=session,
282
293
  before=before,
@@ -299,6 +310,7 @@ class JobManager:
299
310
  job_type: JobType = JobType.JOB,
300
311
  ascending: bool = True,
301
312
  source_id: Optional[str] = None,
313
+ stop_reason: Optional[StopReasonType] = None,
302
314
  ) -> List[PydanticJob]:
303
315
  """List all jobs with optional pagination and status filter."""
304
316
  from sqlalchemy import and_, or_, select
@@ -317,6 +329,10 @@ class JobManager:
317
329
  column = column.op("->>")("source_id")
318
330
  query = query.where(column == source_id)
319
331
 
332
+ # add stop_reason filter if provided
333
+ if stop_reason is not None:
334
+ query = query.where(JobModel.stop_reason == stop_reason)
335
+
320
336
  # handle cursor-based pagination
321
337
  if before or after:
322
338
  # get cursor objects