letta-nightly 0.8.4.dev20250619104255__py3-none-any.whl → 0.8.5.dev20250619180801__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 +1 -1
  2. letta/agents/letta_agent.py +54 -20
  3. letta/agents/voice_agent.py +47 -31
  4. letta/constants.py +1 -1
  5. letta/data_sources/redis_client.py +11 -6
  6. letta/functions/function_sets/builtin.py +35 -11
  7. letta/functions/prompts.py +26 -0
  8. letta/functions/types.py +6 -0
  9. letta/interfaces/openai_chat_completions_streaming_interface.py +0 -1
  10. letta/llm_api/anthropic.py +9 -1
  11. letta/llm_api/anthropic_client.py +8 -11
  12. letta/llm_api/aws_bedrock.py +10 -6
  13. letta/llm_api/llm_api_tools.py +3 -0
  14. letta/llm_api/openai_client.py +1 -1
  15. letta/orm/agent.py +14 -1
  16. letta/orm/job.py +3 -0
  17. letta/orm/provider.py +3 -1
  18. letta/schemas/agent.py +7 -0
  19. letta/schemas/embedding_config.py +8 -0
  20. letta/schemas/enums.py +0 -1
  21. letta/schemas/job.py +1 -0
  22. letta/schemas/providers.py +13 -5
  23. letta/server/rest_api/routers/v1/agents.py +76 -35
  24. letta/server/rest_api/routers/v1/providers.py +7 -7
  25. letta/server/rest_api/routers/v1/sources.py +39 -19
  26. letta/server/rest_api/routers/v1/tools.py +96 -31
  27. letta/services/agent_manager.py +8 -2
  28. letta/services/file_processor/chunker/llama_index_chunker.py +89 -1
  29. letta/services/file_processor/embedder/openai_embedder.py +6 -1
  30. letta/services/file_processor/parser/mistral_parser.py +2 -2
  31. letta/services/helpers/agent_manager_helper.py +44 -16
  32. letta/services/job_manager.py +35 -17
  33. letta/services/mcp/base_client.py +26 -1
  34. letta/services/mcp_manager.py +33 -18
  35. letta/services/provider_manager.py +30 -0
  36. letta/services/tool_executor/builtin_tool_executor.py +335 -43
  37. letta/services/tool_manager.py +25 -1
  38. letta/services/user_manager.py +1 -1
  39. letta/settings.py +3 -0
  40. {letta_nightly-0.8.4.dev20250619104255.dist-info → letta_nightly-0.8.5.dev20250619180801.dist-info}/METADATA +4 -3
  41. {letta_nightly-0.8.4.dev20250619104255.dist-info → letta_nightly-0.8.5.dev20250619180801.dist-info}/RECORD +44 -42
  42. {letta_nightly-0.8.4.dev20250619104255.dist-info → letta_nightly-0.8.5.dev20250619180801.dist-info}/LICENSE +0 -0
  43. {letta_nightly-0.8.4.dev20250619104255.dist-info → letta_nightly-0.8.5.dev20250619180801.dist-info}/WHEEL +0 -0
  44. {letta_nightly-0.8.4.dev20250619104255.dist-info → letta_nightly-0.8.5.dev20250619180801.dist-info}/entry_points.txt +0 -0
@@ -63,6 +63,14 @@ class EmbeddingConfig(BaseModel):
63
63
  embedding_dim=1536,
64
64
  embedding_chunk_size=300,
65
65
  )
66
+ if model_name == "text-embedding-3-small" and provider == "openai":
67
+ return cls(
68
+ embedding_model="text-embedding-3-small",
69
+ embedding_endpoint_type="openai",
70
+ embedding_endpoint="https://api.openai.com/v1",
71
+ embedding_dim=2000,
72
+ embedding_chunk_size=300,
73
+ )
66
74
  elif model_name == "letta":
67
75
  return cls(
68
76
  embedding_endpoint="https://embeddings.memgpt.ai",
letta/schemas/enums.py CHANGED
@@ -3,7 +3,6 @@ from enum import Enum
3
3
 
4
4
  class ProviderType(str, Enum):
5
5
  anthropic = "anthropic"
6
- anthropic_bedrock = "bedrock"
7
6
  google_ai = "google_ai"
8
7
  google_vertex = "google_vertex"
9
8
  openai = "openai"
letta/schemas/job.py CHANGED
@@ -19,6 +19,7 @@ class JobBase(OrmMetadataBase):
19
19
  callback_url: Optional[str] = Field(None, description="If set, POST to this URL when the job completes.")
20
20
  callback_sent_at: Optional[datetime] = Field(None, description="Timestamp when the callback was last attempted.")
21
21
  callback_status_code: Optional[int] = Field(None, description="HTTP status code returned by the callback endpoint.")
22
+ callback_error: Optional[str] = Field(None, description="Optional error message from attempting to POST the callback endpoint.")
22
23
 
23
24
 
24
25
  class Job(JobBase):
@@ -27,8 +27,10 @@ class Provider(ProviderBase):
27
27
  name: str = Field(..., description="The name of the provider")
28
28
  provider_type: ProviderType = Field(..., description="The type of the provider")
29
29
  provider_category: ProviderCategory = Field(..., description="The category of the provider (base or byok)")
30
- api_key: Optional[str] = Field(None, description="API key used for requests to the provider.")
30
+ api_key: Optional[str] = Field(None, description="API key or secret key used for requests to the provider.")
31
31
  base_url: Optional[str] = Field(None, description="Base URL for the provider.")
32
+ access_key: Optional[str] = Field(None, description="Access key used for requests to the provider.")
33
+ region: Optional[str] = Field(None, description="Region used for requests to the provider.")
32
34
  organization_id: Optional[str] = Field(None, description="The organization id of the user")
33
35
  updated_at: Optional[datetime] = Field(None, description="The last update timestamp of the provider.")
34
36
 
@@ -95,7 +97,7 @@ class Provider(ProviderBase):
95
97
  return OpenAIProvider(**self.model_dump(exclude_none=True))
96
98
  case ProviderType.anthropic:
97
99
  return AnthropicProvider(**self.model_dump(exclude_none=True))
98
- case ProviderType.anthropic_bedrock:
100
+ case ProviderType.bedrock:
99
101
  return AnthropicBedrockProvider(**self.model_dump(exclude_none=True))
100
102
  case ProviderType.ollama:
101
103
  return OllamaProvider(**self.model_dump(exclude_none=True))
@@ -122,16 +124,22 @@ class Provider(ProviderBase):
122
124
  class ProviderCreate(ProviderBase):
123
125
  name: str = Field(..., description="The name of the provider.")
124
126
  provider_type: ProviderType = Field(..., description="The type of the provider.")
125
- api_key: str = Field(..., description="API key used for requests to the provider.")
127
+ api_key: str = Field(..., description="API key or secret key used for requests to the provider.")
128
+ access_key: Optional[str] = Field(None, description="Access key used for requests to the provider.")
129
+ region: Optional[str] = Field(None, description="Region used for requests to the provider.")
126
130
 
127
131
 
128
132
  class ProviderUpdate(ProviderBase):
129
- api_key: str = Field(..., description="API key used for requests to the provider.")
133
+ api_key: str = Field(..., description="API key or secret key used for requests to the provider.")
134
+ access_key: Optional[str] = Field(None, description="Access key used for requests to the provider.")
135
+ region: Optional[str] = Field(None, description="Region used for requests to the provider.")
130
136
 
131
137
 
132
138
  class ProviderCheck(BaseModel):
133
139
  provider_type: ProviderType = Field(..., description="The type of the provider.")
134
- api_key: str = Field(..., description="API key used for requests to the provider.")
140
+ api_key: str = Field(..., description="API key or secret key used for requests to the provider.")
141
+ access_key: Optional[str] = Field(None, description="Access key used for requests to the provider.")
142
+ region: Optional[str] = Field(None, description="Region used for requests to the provider.")
135
143
 
136
144
 
137
145
  class LettaProvider(Provider):
@@ -1,9 +1,10 @@
1
+ import asyncio
1
2
  import json
2
3
  import traceback
3
4
  from datetime import datetime, timezone
4
5
  from typing import Annotated, Any, List, Optional
5
6
 
6
- from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, Header, HTTPException, Query, Request, UploadFile, status
7
+ from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, Query, Request, UploadFile, status
7
8
  from fastapi.responses import JSONResponse
8
9
  from marshmallow import ValidationError
9
10
  from orjson import orjson
@@ -79,6 +80,10 @@ async def list_agents(
79
80
  False,
80
81
  description="Whether to sort agents oldest to newest (True) or newest to oldest (False, default)",
81
82
  ),
83
+ sort_by: Optional[str] = Query(
84
+ "created_at",
85
+ description="Field to sort by. Options: 'created_at' (default), 'last_run_completion'",
86
+ ),
82
87
  ):
83
88
  """
84
89
  List all agents associated with a given user.
@@ -107,6 +112,7 @@ async def list_agents(
107
112
  identifier_keys=identifier_keys,
108
113
  include_relationships=include_relationships,
109
114
  ascending=ascending,
115
+ sort_by=sort_by,
110
116
  )
111
117
 
112
118
 
@@ -847,29 +853,63 @@ async def process_message_background(
847
853
  include_return_message_types: Optional[List[MessageType]] = None,
848
854
  ) -> None:
849
855
  """Background task to process the message and update job status."""
856
+ request_start_timestamp_ns = get_utc_timestamp_ns()
850
857
  try:
851
- request_start_timestamp_ns = get_utc_timestamp_ns()
852
- result = await server.send_message_to_agent(
853
- agent_id=agent_id,
854
- actor=actor,
855
- input_messages=messages,
856
- stream_steps=False, # NOTE(matt)
857
- stream_tokens=False,
858
- use_assistant_message=use_assistant_message,
859
- assistant_message_tool_name=assistant_message_tool_name,
860
- assistant_message_tool_kwarg=assistant_message_tool_kwarg,
861
- metadata={"job_id": job_id}, # Pass job_id through metadata
862
- request_start_timestamp_ns=request_start_timestamp_ns,
863
- include_return_message_types=include_return_message_types,
864
- )
858
+ agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"])
859
+ agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
860
+ model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "together", "google_ai", "google_vertex"]
861
+ if agent_eligible and model_compatible:
862
+ if agent.enable_sleeptime and agent.agent_type != AgentType.voice_convo_agent:
863
+ agent_loop = SleeptimeMultiAgentV2(
864
+ agent_id=agent_id,
865
+ message_manager=server.message_manager,
866
+ agent_manager=server.agent_manager,
867
+ block_manager=server.block_manager,
868
+ passage_manager=server.passage_manager,
869
+ group_manager=server.group_manager,
870
+ job_manager=server.job_manager,
871
+ actor=actor,
872
+ group=agent.multi_agent_group,
873
+ )
874
+ else:
875
+ agent_loop = LettaAgent(
876
+ agent_id=agent_id,
877
+ message_manager=server.message_manager,
878
+ agent_manager=server.agent_manager,
879
+ block_manager=server.block_manager,
880
+ passage_manager=server.passage_manager,
881
+ actor=actor,
882
+ step_manager=server.step_manager,
883
+ telemetry_manager=server.telemetry_manager if settings.llm_api_logging else NoopTelemetryManager(),
884
+ )
885
+
886
+ result = await agent_loop.step(
887
+ messages,
888
+ max_steps=max_steps,
889
+ use_assistant_message=use_assistant_message,
890
+ request_start_timestamp_ns=request_start_timestamp_ns,
891
+ include_return_message_types=include_return_message_types,
892
+ )
893
+ else:
894
+ result = await server.send_message_to_agent(
895
+ agent_id=agent_id,
896
+ actor=actor,
897
+ input_messages=messages,
898
+ stream_steps=False,
899
+ stream_tokens=False,
900
+ # Support for AssistantMessage
901
+ use_assistant_message=use_assistant_message,
902
+ assistant_message_tool_name=assistant_message_tool_name,
903
+ assistant_message_tool_kwarg=assistant_message_tool_kwarg,
904
+ include_return_message_types=include_return_message_types,
905
+ )
865
906
 
866
- # Update job status to completed
867
907
  job_update = JobUpdate(
868
908
  status=JobStatus.completed,
869
909
  completed_at=datetime.now(timezone.utc),
870
- metadata={"result": result.model_dump(mode="json")}, # Store the result in metadata
910
+ metadata={"result": result.model_dump(mode="json")},
871
911
  )
872
- server.job_manager.update_job_by_id(job_id=job_id, job_update=job_update, actor=actor)
912
+ await server.job_manager.update_job_by_id_async(job_id=job_id, job_update=job_update, actor=actor)
873
913
 
874
914
  except Exception as e:
875
915
  # Update job status to failed
@@ -878,8 +918,7 @@ async def process_message_background(
878
918
  completed_at=datetime.now(timezone.utc),
879
919
  metadata={"error": str(e)},
880
920
  )
881
- server.job_manager.update_job_by_id(job_id=job_id, job_update=job_update, actor=actor)
882
- raise
921
+ await server.job_manager.update_job_by_id_async(job_id=job_id, job_update=job_update, actor=actor)
883
922
 
884
923
 
885
924
  @router.post(
@@ -889,10 +928,10 @@ async def process_message_background(
889
928
  )
890
929
  async def send_message_async(
891
930
  agent_id: str,
892
- background_tasks: BackgroundTasks,
893
931
  server: SyncServer = Depends(get_letta_server),
894
932
  request: LettaRequest = Body(...),
895
933
  actor_id: Optional[str] = Header(None, alias="user_id"),
934
+ callback_url: Optional[str] = Query(None, description="Optional callback URL to POST to when the job completes"),
896
935
  ):
897
936
  """
898
937
  Asynchronously process a user message and return a run object.
@@ -905,6 +944,7 @@ async def send_message_async(
905
944
  run = Run(
906
945
  user_id=actor.id,
907
946
  status=JobStatus.created,
947
+ callback_url=callback_url,
908
948
  metadata={
909
949
  "job_type": "send_message_async",
910
950
  "agent_id": agent_id,
@@ -915,21 +955,22 @@ async def send_message_async(
915
955
  assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
916
956
  ),
917
957
  )
918
- run = server.job_manager.create_job(pydantic_job=run, actor=actor)
958
+ run = await server.job_manager.create_job_async(pydantic_job=run, actor=actor)
919
959
 
920
- # Add the background task
921
- background_tasks.add_task(
922
- process_message_background,
923
- job_id=run.id,
924
- server=server,
925
- actor=actor,
926
- agent_id=agent_id,
927
- messages=request.messages,
928
- use_assistant_message=request.use_assistant_message,
929
- assistant_message_tool_name=request.assistant_message_tool_name,
930
- assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
931
- max_steps=request.max_steps,
932
- include_return_message_types=request.include_return_message_types,
960
+ # Create asyncio task for background processing
961
+ asyncio.create_task(
962
+ process_message_background(
963
+ job_id=run.id,
964
+ server=server,
965
+ actor=actor,
966
+ agent_id=agent_id,
967
+ messages=request.messages,
968
+ use_assistant_message=request.use_assistant_message,
969
+ assistant_message_tool_name=request.assistant_message_tool_name,
970
+ assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
971
+ max_steps=request.max_steps,
972
+ include_return_message_types=request.include_return_message_types,
973
+ )
933
974
  )
934
975
 
935
976
  return run
@@ -66,20 +66,20 @@ async def modify_provider(
66
66
  """
67
67
  Update an existing custom provider
68
68
  """
69
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
70
- return server.provider_manager.update_provider(provider_id=provider_id, provider_update=request, actor=actor)
69
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
70
+ return await server.provider_manager.update_provider_async(provider_id=provider_id, provider_update=request, actor=actor)
71
71
 
72
72
 
73
73
  @router.get("/check", response_model=None, operation_id="check_provider")
74
74
  def check_provider(
75
- provider_type: ProviderType = Query(...),
76
- api_key: str = Header(..., alias="x-api-key"),
75
+ request: ProviderCheck = Body(...),
77
76
  server: "SyncServer" = Depends(get_letta_server),
78
77
  ):
79
78
  try:
80
- provider_check = ProviderCheck(provider_type=provider_type, api_key=api_key)
81
- server.provider_manager.check_provider_api_key(provider_check=provider_check)
82
- return JSONResponse(status_code=status.HTTP_200_OK, content={"message": f"Valid api key for provider_type={provider_type.value}"})
79
+ server.provider_manager.check_provider_api_key(provider_check=request)
80
+ return JSONResponse(
81
+ status_code=status.HTTP_200_OK, content={"message": f"Valid api key for provider_type={request.provider_type.value}"}
82
+ )
83
83
  except LLMAuthenticationError as e:
84
84
  raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"{e.message}")
85
85
  except Exception as e:
@@ -11,6 +11,7 @@ from starlette import status
11
11
  import letta.constants as constants
12
12
  from letta.log import get_logger
13
13
  from letta.schemas.agent import AgentState
14
+ from letta.schemas.embedding_config import EmbeddingConfig
14
15
  from letta.schemas.file import FileMetadata
15
16
  from letta.schemas.job import Job
16
17
  from letta.schemas.passage import Passage
@@ -21,9 +22,14 @@ from letta.server.server import SyncServer
21
22
  from letta.services.file_processor.chunker.llama_index_chunker import LlamaIndexChunker
22
23
  from letta.services.file_processor.embedder.openai_embedder import OpenAIEmbedder
23
24
  from letta.services.file_processor.file_processor import FileProcessor
24
- from letta.services.file_processor.file_types import get_allowed_media_types, get_extension_to_mime_type_map, register_mime_types
25
+ from letta.services.file_processor.file_types import (
26
+ get_allowed_media_types,
27
+ get_extension_to_mime_type_map,
28
+ is_simple_text_mime_type,
29
+ register_mime_types,
30
+ )
25
31
  from letta.services.file_processor.parser.mistral_parser import MistralFileParser
26
- from letta.settings import model_settings, settings
32
+ from letta.settings import settings
27
33
  from letta.utils import safe_create_task, sanitize_filename
28
34
 
29
35
  logger = get_logger(__name__)
@@ -184,7 +190,7 @@ async def upload_file_to_source(
184
190
  raw_ct = file.content_type or ""
185
191
  media_type = raw_ct.split(";", 1)[0].strip().lower()
186
192
 
187
- # If client didnt supply a Content-Type or its not one of the allowed types,
193
+ # If client didn't supply a Content-Type or it's not one of the allowed types,
188
194
  # attempt to infer from filename extension.
189
195
  if media_type not in allowed_media_types and file.filename:
190
196
  guessed, _ = mimetypes.guess_type(file.filename)
@@ -211,6 +217,7 @@ async def upload_file_to_source(
211
217
  source = await server.source_manager.get_source_by_id(source_id=source_id, actor=actor)
212
218
  if source is None:
213
219
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Source with id={source_id} not found.")
220
+
214
221
  content = await file.read()
215
222
 
216
223
  # sanitize filename
@@ -228,20 +235,26 @@ async def upload_file_to_source(
228
235
  agent_states = await server.source_manager.list_attached_agents(source_id=source_id, actor=actor)
229
236
 
230
237
  # NEW: Cloud based file processing
231
- if settings.mistral_api_key and model_settings.openai_api_key:
232
- logger.info("Running experimental cloud based file processing...")
233
- safe_create_task(
234
- load_file_to_source_cloud(server, agent_states, content, file, job, source_id, actor),
235
- logger=logger,
236
- label="file_processor.process",
237
- )
238
- else:
239
- # create background tasks
240
- safe_create_task(
241
- load_file_to_source_async(server, source_id=source.id, filename=file.filename, job_id=job.id, bytes=content, actor=actor),
242
- logger=logger,
243
- label="load_file_to_source_async",
238
+ # Determine file's MIME type
239
+ file_mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
240
+
241
+ # Check if it's a simple text file
242
+ is_simple_file = is_simple_text_mime_type(file_mime_type)
243
+
244
+ # For complex files, require Mistral API key
245
+ if not is_simple_file and not settings.mistral_api_key:
246
+ raise HTTPException(
247
+ status_code=status.HTTP_400_BAD_REQUEST,
248
+ detail=f"Mistral API key is required to process this file type {file_mime_type}. Please configure your Mistral API key to upload complex file formats.",
244
249
  )
250
+
251
+ # Use cloud processing for all files (simple files always, complex files with Mistral key)
252
+ logger.info("Running experimental cloud based file processing...")
253
+ safe_create_task(
254
+ load_file_to_source_cloud(server, agent_states, content, file, job, source_id, actor, source.embedding_config),
255
+ logger=logger,
256
+ label="file_processor.process",
257
+ )
245
258
  safe_create_task(sleeptime_document_ingest_async(server, source_id, actor), logger=logger, label="sleeptime_document_ingest_async")
246
259
 
247
260
  return job
@@ -336,10 +349,17 @@ async def sleeptime_document_ingest_async(server: SyncServer, source_id: str, ac
336
349
 
337
350
 
338
351
  async def load_file_to_source_cloud(
339
- server: SyncServer, agent_states: List[AgentState], content: bytes, file: UploadFile, job: Job, source_id: str, actor: User
352
+ server: SyncServer,
353
+ agent_states: List[AgentState],
354
+ content: bytes,
355
+ file: UploadFile,
356
+ job: Job,
357
+ source_id: str,
358
+ actor: User,
359
+ embedding_config: EmbeddingConfig,
340
360
  ):
341
361
  file_processor = MistralFileParser()
342
- text_chunker = LlamaIndexChunker()
343
- embedder = OpenAIEmbedder()
362
+ text_chunker = LlamaIndexChunker(chunk_size=embedding_config.embedding_chunk_size)
363
+ embedder = OpenAIEmbedder(embedding_config=embedding_config)
344
364
  file_processor = FileProcessor(file_parser=file_processor, text_chunker=text_chunker, embedder=embedder, actor=actor)
345
365
  await file_processor.process(server=server, agent_states=agent_states, source_id=source_id, content=content, file=file, job=job)
@@ -13,11 +13,12 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
13
13
 
14
14
  from letta.errors import LettaToolCreateError
15
15
  from letta.functions.mcp_client.exceptions import MCPTimeoutError
16
- from letta.functions.mcp_client.types import MCPTool, SSEServerConfig, StdioServerConfig
16
+ from letta.functions.mcp_client.types import MCPTool, SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig
17
17
  from letta.helpers.composio_helpers import get_composio_api_key
18
18
  from letta.log import get_logger
19
19
  from letta.orm.errors import UniqueConstraintViolationError
20
20
  from letta.schemas.letta_message import ToolReturnMessage
21
+ from letta.schemas.mcp import UpdateSSEMCPServer, UpdateStreamableHTTPMCPServer
21
22
  from letta.schemas.tool import Tool, ToolCreate, ToolRunFromSource, ToolUpdate
22
23
  from letta.server.rest_api.utils import get_letta_server
23
24
  from letta.server.server import SyncServer
@@ -91,6 +92,8 @@ async def list_tools(
91
92
  if name is not None:
92
93
  tool = await server.tool_manager.get_tool_by_name_async(tool_name=name, actor=actor)
93
94
  return [tool] if tool else []
95
+
96
+ # Get the list of tools
94
97
  return await server.tool_manager.list_tools_async(actor=actor, after=after, limit=limit)
95
98
  except Exception as e:
96
99
  # Log or print the full exception here for debugging
@@ -338,20 +341,20 @@ async def add_composio_tool(
338
341
  "composio_action_name": composio_action_name,
339
342
  },
340
343
  )
341
- except ComposioClientError as e:
344
+ except ApiKeyNotProvidedError as e:
342
345
  raise HTTPException(
343
346
  status_code=400, # Bad Request
344
347
  detail={
345
- "code": "ComposioClientError",
348
+ "code": "ApiKeyNotProvidedError",
346
349
  "message": str(e),
347
350
  "composio_action_name": composio_action_name,
348
351
  },
349
352
  )
350
- except ApiKeyNotProvidedError as e:
353
+ except ComposioClientError as e:
351
354
  raise HTTPException(
352
355
  status_code=400, # Bad Request
353
356
  detail={
354
- "code": "ApiKeyNotProvidedError",
357
+ "code": "ComposioClientError",
355
358
  "message": str(e),
356
359
  "composio_action_name": composio_action_name,
357
360
  },
@@ -368,7 +371,11 @@ async def add_composio_tool(
368
371
 
369
372
 
370
373
  # Specific routes for MCP
371
- @router.get("/mcp/servers", response_model=dict[str, Union[SSEServerConfig, StdioServerConfig]], operation_id="list_mcp_servers")
374
+ @router.get(
375
+ "/mcp/servers",
376
+ response_model=dict[str, Union[SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig]],
377
+ operation_id="list_mcp_servers",
378
+ )
372
379
  async def list_mcp_servers(server: SyncServer = Depends(get_letta_server), user_id: Optional[str] = Header(None, alias="user_id")):
373
380
  """
374
381
  Get a list of all configured MCP servers
@@ -479,44 +486,102 @@ async def add_mcp_tool(
479
486
  return await server.mcp_manager.add_tool_from_mcp_server(mcp_server_name=mcp_server_name, mcp_tool_name=mcp_tool_name, actor=actor)
480
487
 
481
488
 
482
- @router.put("/mcp/servers", response_model=List[Union[StdioServerConfig, SSEServerConfig]], operation_id="add_mcp_server")
489
+ @router.put(
490
+ "/mcp/servers",
491
+ response_model=List[Union[StdioServerConfig, SSEServerConfig, StreamableHTTPServerConfig]],
492
+ operation_id="add_mcp_server",
493
+ )
483
494
  async def add_mcp_server_to_config(
484
- request: Union[StdioServerConfig, SSEServerConfig] = Body(...),
495
+ request: Union[StdioServerConfig, SSEServerConfig, StreamableHTTPServerConfig] = Body(...),
485
496
  server: SyncServer = Depends(get_letta_server),
486
497
  actor_id: Optional[str] = Header(None, alias="user_id"),
487
498
  ):
488
499
  """
489
500
  Add a new MCP server to the Letta MCP server config
490
501
  """
502
+ try:
503
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
491
504
 
492
- actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
505
+ if tool_settings.mcp_read_from_config:
506
+ # write to config file
507
+ return await server.add_mcp_server_to_config(server_config=request, allow_upsert=True)
508
+ else:
509
+ # log to DB
510
+ from letta.schemas.mcp import MCPServer
511
+
512
+ if isinstance(request, StdioServerConfig):
513
+ mapped_request = MCPServer(server_name=request.server_name, server_type=request.type, stdio_config=request)
514
+ # don't allow stdio servers
515
+ if tool_settings.mcp_disable_stdio: # protected server
516
+ raise HTTPException(
517
+ status_code=400,
518
+ detail="stdio is not supported in the current environment, please use a self-hosted Letta server in order to add a stdio MCP server",
519
+ )
520
+ elif isinstance(request, SSEServerConfig):
521
+ mapped_request = MCPServer(
522
+ server_name=request.server_name, server_type=request.type, server_url=request.server_url, token=request.resolve_token()
523
+ )
524
+ elif isinstance(request, StreamableHTTPServerConfig):
525
+ mapped_request = MCPServer(
526
+ server_name=request.server_name, server_type=request.type, server_url=request.server_url, token=request.resolve_token()
527
+ )
528
+
529
+ await server.mcp_manager.create_mcp_server(mapped_request, actor=actor)
530
+
531
+ # TODO: don't do this in the future (just return MCPServer)
532
+ all_servers = await server.mcp_manager.list_mcp_servers(actor=actor)
533
+ return [server.to_config() for server in all_servers]
534
+ except UniqueConstraintViolationError:
535
+ # If server name already exists, throw 409 conflict error
536
+ raise HTTPException(
537
+ status_code=409,
538
+ detail={
539
+ "code": "MCPServerNameAlreadyExistsError",
540
+ "message": f"MCP server with name '{request.server_name}' already exists",
541
+ "server_name": request.server_name,
542
+ },
543
+ )
544
+ except Exception as e:
545
+ print(f"Unexpected error occurred while adding MCP server: {e}")
546
+ raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
493
547
 
494
- if tool_settings.mcp_read_from_config:
495
- # write to config file
496
- return await server.add_mcp_server_to_config(server_config=request, allow_upsert=True)
497
- else:
498
- # log to DB
499
- from letta.schemas.mcp import MCPServer
500
-
501
- if isinstance(request, StdioServerConfig):
502
- mapped_request = MCPServer(server_name=request.server_name, server_type=request.type, stdio_config=request)
503
- # don't allow stdio servers
504
- if tool_settings.mcp_disable_stdio: # protected server
505
- raise HTTPException(status_code=400, detail="StdioServerConfig is not supported")
506
- elif isinstance(request, SSEServerConfig):
507
- mapped_request = MCPServer(
508
- server_name=request.server_name, server_type=request.type, server_url=request.server_url, token=request.resolve_token()
509
- )
510
- # TODO: add HTTP streaming
511
- mcp_server = await server.mcp_manager.create_or_update_mcp_server(mapped_request, actor=actor)
512
548
 
513
- # TODO: don't do this in the future (just return MCPServer)
514
- all_servers = await server.mcp_manager.list_mcp_servers(actor=actor)
515
- return [server.to_config() for server in all_servers]
549
+ @router.patch(
550
+ "/mcp/servers/{mcp_server_name}",
551
+ response_model=Union[StdioServerConfig, SSEServerConfig, StreamableHTTPServerConfig],
552
+ operation_id="update_mcp_server",
553
+ )
554
+ async def update_mcp_server(
555
+ mcp_server_name: str,
556
+ request: Union[UpdateSSEMCPServer, UpdateStreamableHTTPMCPServer] = Body(...),
557
+ server: SyncServer = Depends(get_letta_server),
558
+ actor_id: Optional[str] = Header(None, alias="user_id"),
559
+ ):
560
+ """
561
+ Update an existing MCP server configuration
562
+ """
563
+ try:
564
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
565
+
566
+ if tool_settings.mcp_read_from_config:
567
+ raise HTTPException(status_code=501, detail="Update not implemented for config file mode, config files to be deprecated.")
568
+ else:
569
+ updated_server = await server.mcp_manager.update_mcp_server_by_name(
570
+ mcp_server_name=mcp_server_name, mcp_server_update=request, actor=actor
571
+ )
572
+ return updated_server.to_config()
573
+ except HTTPException:
574
+ # Re-raise HTTP exceptions (like 404)
575
+ raise
576
+ except Exception as e:
577
+ print(f"Unexpected error occurred while updating MCP server: {e}")
578
+ raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
516
579
 
517
580
 
518
581
  @router.delete(
519
- "/mcp/servers/{mcp_server_name}", response_model=List[Union[StdioServerConfig, SSEServerConfig]], operation_id="delete_mcp_server"
582
+ "/mcp/servers/{mcp_server_name}",
583
+ response_model=List[Union[StdioServerConfig, SSEServerConfig, StreamableHTTPServerConfig]],
584
+ operation_id="delete_mcp_server",
520
585
  )
521
586
  async def delete_mcp_server_from_config(
522
587
  mcp_server_name: str,
@@ -622,6 +622,8 @@ class AgentManager:
622
622
  "message_buffer_autoclear": agent_update.message_buffer_autoclear,
623
623
  "enable_sleeptime": agent_update.enable_sleeptime,
624
624
  "response_format": agent_update.response_format,
625
+ "last_run_completion": agent_update.last_run_completion,
626
+ "last_run_duration_ms": agent_update.last_run_duration_ms,
625
627
  }
626
628
  for col, val in scalar_updates.items():
627
629
  if val is not None:
@@ -742,6 +744,8 @@ class AgentManager:
742
744
  "message_buffer_autoclear": agent_update.message_buffer_autoclear,
743
745
  "enable_sleeptime": agent_update.enable_sleeptime,
744
746
  "response_format": agent_update.response_format,
747
+ "last_run_completion": agent_update.last_run_completion,
748
+ "last_run_duration_ms": agent_update.last_run_duration_ms,
745
749
  }
746
750
  for col, val in scalar_updates.items():
747
751
  if val is not None:
@@ -844,6 +848,7 @@ class AgentManager:
844
848
  identifier_keys: Optional[List[str]] = None,
845
849
  include_relationships: Optional[List[str]] = None,
846
850
  ascending: bool = True,
851
+ sort_by: Optional[str] = "created_at",
847
852
  ) -> List[PydanticAgentState]:
848
853
  """
849
854
  Retrieves agents with optimized filtering and optional field selection.
@@ -876,7 +881,7 @@ class AgentManager:
876
881
  query = _apply_filters(query, name, query_text, project_id, template_id, base_template_id)
877
882
  query = _apply_identity_filters(query, identity_id, identifier_keys)
878
883
  query = _apply_tag_filter(query, tags, match_all_tags)
879
- query = _apply_pagination(query, before, after, session, ascending=ascending)
884
+ query = _apply_pagination(query, before, after, session, ascending=ascending, sort_by=sort_by)
880
885
 
881
886
  if limit:
882
887
  query = query.limit(limit)
@@ -903,6 +908,7 @@ class AgentManager:
903
908
  identifier_keys: Optional[List[str]] = None,
904
909
  include_relationships: Optional[List[str]] = None,
905
910
  ascending: bool = True,
911
+ sort_by: Optional[str] = "created_at",
906
912
  ) -> List[PydanticAgentState]:
907
913
  """
908
914
  Retrieves agents with optimized filtering and optional field selection.
@@ -935,7 +941,7 @@ class AgentManager:
935
941
  query = _apply_filters(query, name, query_text, project_id, template_id, base_template_id)
936
942
  query = _apply_identity_filters(query, identity_id, identifier_keys)
937
943
  query = _apply_tag_filter(query, tags, match_all_tags)
938
- query = await _apply_pagination_async(query, before, after, session, ascending=ascending)
944
+ query = await _apply_pagination_async(query, before, after, session, ascending=ascending, sort_by=sort_by)
939
945
 
940
946
  if limit:
941
947
  query = query.limit(limit)