letta-nightly 0.9.0.dev20250726104256__py3-none-any.whl → 0.9.1.dev20250727104258__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 (54) hide show
  1. letta/__init__.py +1 -1
  2. letta/agents/base_agent.py +1 -1
  3. letta/agents/letta_agent.py +6 -0
  4. letta/helpers/datetime_helpers.py +1 -1
  5. letta/helpers/json_helpers.py +1 -1
  6. letta/orm/agent.py +2 -3
  7. letta/orm/agents_tags.py +1 -0
  8. letta/orm/block.py +2 -2
  9. letta/orm/group.py +2 -2
  10. letta/orm/identity.py +3 -4
  11. letta/orm/mcp_oauth.py +62 -0
  12. letta/orm/step.py +2 -4
  13. letta/schemas/agent_file.py +31 -5
  14. letta/schemas/block.py +3 -0
  15. letta/schemas/enums.py +4 -0
  16. letta/schemas/group.py +3 -0
  17. letta/schemas/mcp.py +70 -0
  18. letta/schemas/memory.py +35 -0
  19. letta/schemas/message.py +98 -91
  20. letta/schemas/providers/openai.py +1 -1
  21. letta/server/rest_api/app.py +19 -21
  22. letta/server/rest_api/middleware/__init__.py +4 -0
  23. letta/server/rest_api/middleware/check_password.py +24 -0
  24. letta/server/rest_api/middleware/profiler_context.py +25 -0
  25. letta/server/rest_api/routers/v1/blocks.py +2 -0
  26. letta/server/rest_api/routers/v1/groups.py +1 -1
  27. letta/server/rest_api/routers/v1/sources.py +26 -0
  28. letta/server/rest_api/routers/v1/tools.py +224 -23
  29. letta/services/agent_manager.py +15 -9
  30. letta/services/agent_serialization_manager.py +84 -3
  31. letta/services/block_manager.py +4 -0
  32. letta/services/file_manager.py +23 -13
  33. letta/services/file_processor/file_processor.py +12 -10
  34. letta/services/mcp/base_client.py +20 -28
  35. letta/services/mcp/oauth_utils.py +433 -0
  36. letta/services/mcp/sse_client.py +12 -1
  37. letta/services/mcp/streamable_http_client.py +17 -5
  38. letta/services/mcp/types.py +9 -0
  39. letta/services/mcp_manager.py +304 -42
  40. letta/services/provider_manager.py +2 -2
  41. letta/services/tool_executor/tool_executor.py +6 -2
  42. letta/services/tool_manager.py +8 -4
  43. letta/services/tool_sandbox/base.py +3 -3
  44. letta/services/tool_sandbox/e2b_sandbox.py +1 -1
  45. letta/services/tool_sandbox/local_sandbox.py +16 -9
  46. letta/settings.py +11 -1
  47. letta/system.py +1 -1
  48. letta/templates/template_helper.py +25 -1
  49. letta/utils.py +19 -35
  50. {letta_nightly-0.9.0.dev20250726104256.dist-info → letta_nightly-0.9.1.dev20250727104258.dist-info}/METADATA +3 -2
  51. {letta_nightly-0.9.0.dev20250726104256.dist-info → letta_nightly-0.9.1.dev20250727104258.dist-info}/RECORD +54 -49
  52. {letta_nightly-0.9.0.dev20250726104256.dist-info → letta_nightly-0.9.1.dev20250727104258.dist-info}/LICENSE +0 -0
  53. {letta_nightly-0.9.0.dev20250726104256.dist-info → letta_nightly-0.9.1.dev20250727104258.dist-info}/WHEEL +0 -0
  54. {letta_nightly-0.9.0.dev20250726104256.dist-info → letta_nightly-0.9.1.dev20250727104258.dist-info}/entry_points.txt +0 -0
letta/schemas/message.py CHANGED
@@ -41,7 +41,7 @@ from letta.schemas.letta_message_content import (
41
41
  get_letta_message_content_union_str_json_schema,
42
42
  )
43
43
  from letta.system import unpack_message
44
- from letta.utils import parse_json
44
+ from letta.utils import parse_json, validate_function_response
45
45
 
46
46
 
47
47
  def add_inner_thoughts_to_tool_call(
@@ -251,10 +251,10 @@ class Message(BaseMessage):
251
251
  include_err: Optional[bool] = None,
252
252
  ) -> List[LettaMessage]:
253
253
  """Convert message object (in DB format) to the style used by the original Letta API"""
254
- messages = []
255
254
 
255
+ # TODO (cliandy): break this into more manageable pieces
256
256
  if self.role == MessageRole.assistant:
257
-
257
+ messages = []
258
258
  # Handle reasoning
259
259
  if self.content:
260
260
  # Check for ReACT-style COT inside of TextContent
@@ -348,7 +348,7 @@ class Message(BaseMessage):
348
348
  # We need to unpack the actual message contents from the function call
349
349
  try:
350
350
  func_args = parse_json(tool_call.function.arguments)
351
- message_string = func_args[assistant_message_tool_kwarg]
351
+ message_string = validate_function_response(func_args[assistant_message_tool_kwarg], 0, truncate=False)
352
352
  except KeyError:
353
353
  raise ValueError(f"Function call {tool_call.function.name} missing {assistant_message_tool_kwarg} argument")
354
354
  messages.append(
@@ -380,99 +380,106 @@ class Message(BaseMessage):
380
380
  is_err=self.is_err,
381
381
  )
382
382
  )
383
- elif self.role == MessageRole.tool:
384
- # This is type ToolReturnMessage
385
- # Try to interpret the function return, recall that this is how we packaged:
386
- # def package_function_response(was_success, response_string, timestamp=None):
387
- # formatted_time = get_local_time() if timestamp is None else timestamp
388
- # packaged_message = {
389
- # "status": "OK" if was_success else "Failed",
390
- # "message": response_string,
391
- # "time": formatted_time,
392
- # }
393
- if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
394
- text_content = self.content[0].text
395
- else:
396
- raise ValueError(f"Invalid tool return (no text object on message): {self.content}")
397
383
 
398
- try:
399
- function_return = parse_json(text_content)
400
- text_content = str(function_return.get("message", text_content))
401
- status = function_return["status"]
402
- if status == "OK":
403
- status_enum = "success"
404
- elif status == "Failed":
405
- status_enum = "error"
406
- else:
407
- raise ValueError(f"Invalid status: {status}")
408
- except json.JSONDecodeError:
409
- raise ValueError(f"Failed to decode function return: {text_content}")
410
- assert self.tool_call_id is not None
411
- messages.append(
412
- # TODO make sure this is what the API returns
413
- # function_return may not match exactly...
414
- ToolReturnMessage(
415
- id=self.id,
416
- date=self.created_at,
417
- tool_return=text_content,
418
- status=self.tool_returns[0].status if self.tool_returns else status_enum,
419
- tool_call_id=self.tool_call_id,
420
- stdout=self.tool_returns[0].stdout if self.tool_returns else None,
421
- stderr=self.tool_returns[0].stderr if self.tool_returns else None,
422
- name=self.name,
423
- otid=Message.generate_otid_from_id(self.id, len(messages)),
424
- sender_id=self.sender_id,
425
- step_id=self.step_id,
426
- is_err=self.is_err,
427
- )
428
- )
384
+ elif self.role == MessageRole.tool:
385
+ messages = [self._convert_tool_message()]
429
386
  elif self.role == MessageRole.user:
430
- # This is type UserMessage
431
- if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
432
- text_content = self.content[0].text
433
- elif self.content:
434
- text_content = self.content
435
- else:
436
- raise ValueError(f"Invalid user message (no text object on message): {self.content}")
437
-
438
- message = unpack_message(text_content)
439
- messages.append(
440
- UserMessage(
441
- id=self.id,
442
- date=self.created_at,
443
- content=message,
444
- name=self.name,
445
- otid=self.otid,
446
- sender_id=self.sender_id,
447
- step_id=self.step_id,
448
- is_err=self.is_err,
449
- )
450
- )
387
+ messages = [self._convert_user_message()]
451
388
  elif self.role == MessageRole.system:
452
- # This is type SystemMessage
453
- if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
454
- text_content = self.content[0].text
455
- else:
456
- raise ValueError(f"Invalid system message (no text object on system): {self.content}")
457
-
458
- messages.append(
459
- SystemMessage(
460
- id=self.id,
461
- date=self.created_at,
462
- content=text_content,
463
- name=self.name,
464
- otid=self.otid,
465
- sender_id=self.sender_id,
466
- step_id=self.step_id,
467
- )
468
- )
389
+ messages = [self._convert_system_message()]
469
390
  else:
470
- raise ValueError(self.role)
391
+ raise ValueError(f"Unknown role: {self.role}")
392
+
393
+ return messages[::-1] if reverse else messages
471
394
 
472
- if reverse:
473
- messages.reverse()
395
+ def _convert_tool_message(self) -> ToolReturnMessage:
396
+ """Convert tool role message to ToolReturnMessage
474
397
 
475
- return messages
398
+ the tool return is packaged as follows:
399
+ packaged_message = {
400
+ "status": "OK" if was_success else "Failed",
401
+ "message": response_string,
402
+ "time": formatted_time,
403
+ }
404
+ """
405
+ if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
406
+ text_content = self.content[0].text
407
+ else:
408
+ raise ValueError(f"Invalid tool return (no text object on message): {self.content}")
409
+
410
+ try:
411
+ function_return = parse_json(text_content)
412
+ message_text = str(function_return.get("message", text_content))
413
+ status = self._parse_tool_status(function_return["status"])
414
+ except json.JSONDecodeError:
415
+ raise ValueError(f"Failed to decode function return: {text_content}")
416
+
417
+ assert self.tool_call_id is not None
418
+
419
+ return ToolReturnMessage(
420
+ id=self.id,
421
+ date=self.created_at,
422
+ tool_return=message_text,
423
+ status=self.tool_returns[0].status if self.tool_returns else status,
424
+ tool_call_id=self.tool_call_id,
425
+ stdout=self.tool_returns[0].stdout if self.tool_returns else None,
426
+ stderr=self.tool_returns[0].stderr if self.tool_returns else None,
427
+ name=self.name,
428
+ otid=Message.generate_otid_from_id(self.id, 0),
429
+ sender_id=self.sender_id,
430
+ step_id=self.step_id,
431
+ is_err=self.is_err,
432
+ )
433
+
434
+ @staticmethod
435
+ def _parse_tool_status(status: str) -> Literal["success", "error"]:
436
+ """Convert tool status string to enum value"""
437
+ if status == "OK":
438
+ return "success"
439
+ elif status == "Failed":
440
+ return "error"
441
+ else:
442
+ raise ValueError(f"Invalid status: {status}")
443
+
444
+ def _convert_user_message(self) -> UserMessage:
445
+ """Convert user role message to UserMessage"""
446
+ # Extract text content
447
+ if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
448
+ text_content = self.content[0].text
449
+ elif self.content:
450
+ text_content = self.content
451
+ else:
452
+ raise ValueError(f"Invalid user message (no text object on message): {self.content}")
453
+
454
+ message = unpack_message(text_content)
455
+
456
+ return UserMessage(
457
+ id=self.id,
458
+ date=self.created_at,
459
+ content=message,
460
+ name=self.name,
461
+ otid=self.otid,
462
+ sender_id=self.sender_id,
463
+ step_id=self.step_id,
464
+ is_err=self.is_err,
465
+ )
466
+
467
+ def _convert_system_message(self) -> SystemMessage:
468
+ """Convert system role message to SystemMessage"""
469
+ if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
470
+ text_content = self.content[0].text
471
+ else:
472
+ raise ValueError(f"Invalid system message (no text object on system): {self.content}")
473
+
474
+ return SystemMessage(
475
+ id=self.id,
476
+ date=self.created_at,
477
+ content=text_content,
478
+ name=self.name,
479
+ otid=self.otid,
480
+ sender_id=self.sender_id,
481
+ step_id=self.step_id,
482
+ )
476
483
 
477
484
  @staticmethod
478
485
  def dict_to_message(
@@ -202,7 +202,7 @@ class OpenAIProvider(Provider):
202
202
  if model_type not in ["text->embedding"]:
203
203
  continue
204
204
  else:
205
- logger.info(
205
+ logger.debug(
206
206
  f"Skipping embedding models for %s by default, as we don't assume embeddings are supported."
207
207
  "Please open an issue on GitHub if support is required.",
208
208
  self.base_url,
@@ -12,7 +12,6 @@ from typing import Optional
12
12
  import uvicorn
13
13
  from fastapi import FastAPI, Request
14
14
  from fastapi.responses import JSONResponse
15
- from starlette.middleware.base import BaseHTTPMiddleware
16
15
  from starlette.middleware.cors import CORSMiddleware
17
16
 
18
17
  from letta.__init__ import __version__ as letta_version
@@ -35,6 +34,7 @@ from letta.server.db import db_registry
35
34
  # NOTE(charles): these are extra routes that are not part of v1 but we still need to mount to pass tests
36
35
  from letta.server.rest_api.auth.index import setup_auth_router # TODO: probably remove right?
37
36
  from letta.server.rest_api.interface import StreamingServerInterface
37
+ from letta.server.rest_api.middleware import CheckPasswordMiddleware, ProfilerContextMiddleware
38
38
  from letta.server.rest_api.routers.openai.chat_completions.chat_completions import router as openai_chat_completions_router
39
39
  from letta.server.rest_api.routers.v1 import ROUTERS as v1_routes
40
40
  from letta.server.rest_api.routers.v1.organizations import router as organizations_router
@@ -42,7 +42,7 @@ from letta.server.rest_api.routers.v1.users import router as users_router # TOD
42
42
  from letta.server.rest_api.static_files import mount_static_files
43
43
  from letta.server.rest_api.utils import SENTRY_ENABLED
44
44
  from letta.server.server import SyncServer
45
- from letta.settings import settings
45
+ from letta.settings import settings, telemetry_settings
46
46
 
47
47
  if SENTRY_ENABLED:
48
48
  import sentry_sdk
@@ -92,24 +92,6 @@ def generate_password():
92
92
  random_password = os.getenv("LETTA_SERVER_PASSWORD") or generate_password()
93
93
 
94
94
 
95
- class CheckPasswordMiddleware(BaseHTTPMiddleware):
96
- async def dispatch(self, request, call_next):
97
- # Exclude health check endpoint from password protection
98
- if request.url.path in {"/v1/health", "/v1/health/", "/latest/health/"}:
99
- return await call_next(request)
100
-
101
- if (
102
- request.headers.get("X-BARE-PASSWORD") == f"password {random_password}"
103
- or request.headers.get("Authorization") == f"Bearer {random_password}"
104
- ):
105
- return await call_next(request)
106
-
107
- return JSONResponse(
108
- content={"detail": "Unauthorized"},
109
- status_code=401,
110
- )
111
-
112
-
113
95
  @asynccontextmanager
114
96
  async def lifespan(app_: FastAPI):
115
97
  """
@@ -117,6 +99,19 @@ async def lifespan(app_: FastAPI):
117
99
  """
118
100
  worker_id = os.getpid()
119
101
 
102
+ if telemetry_settings.profiler:
103
+ try:
104
+ import googlecloudprofiler
105
+
106
+ googlecloudprofiler.start(
107
+ service="memgpt-server",
108
+ service_version=str(letta_version),
109
+ verbose=3,
110
+ )
111
+ logger.info("Profiler started.")
112
+ except Exception as exc:
113
+ logger.info("Profiler not enabled: %", exc)
114
+
120
115
  logger.info(f"[Worker {worker_id}] Starting lifespan initialization")
121
116
  logger.info(f"[Worker {worker_id}] Initializing database connections")
122
117
  db_registry.initialize_sync()
@@ -283,11 +278,14 @@ def create_application() -> "FastAPI":
283
278
 
284
279
  if (os.getenv("LETTA_SERVER_SECURE") == "true") or "--secure" in sys.argv:
285
280
  print(f"▶ Using secure mode with password: {random_password}")
286
- app.add_middleware(CheckPasswordMiddleware)
281
+ app.add_middleware(CheckPasswordMiddleware, password=random_password)
287
282
 
288
283
  # Add reverse proxy middleware to handle X-Forwarded-* headers
289
284
  # app.add_middleware(ReverseProxyMiddleware, base_path=settings.server_base_path)
290
285
 
286
+ if telemetry_settings.profiler:
287
+ app.add_middleware(ProfilerContextMiddleware)
288
+
291
289
  app.add_middleware(
292
290
  CORSMiddleware,
293
291
  allow_origins=settings.cors_origins,
@@ -0,0 +1,4 @@
1
+ from letta.server.rest_api.middleware.check_password import CheckPasswordMiddleware
2
+ from letta.server.rest_api.middleware.profiler_context import ProfilerContextMiddleware
3
+
4
+ __all__ = ["CheckPasswordMiddleware", "ProfilerContextMiddleware"]
@@ -0,0 +1,24 @@
1
+ from starlette.middleware.base import BaseHTTPMiddleware
2
+ from starlette.responses import JSONResponse
3
+
4
+
5
+ class CheckPasswordMiddleware(BaseHTTPMiddleware):
6
+ def __init__(self, app, password: str):
7
+ super().__init__(app)
8
+ self.password = password
9
+
10
+ async def dispatch(self, request, call_next):
11
+ # Exclude health check endpoint from password protection
12
+ if request.url.path in {"/v1/health", "/v1/health/", "/latest/health/"}:
13
+ return await call_next(request)
14
+
15
+ if (
16
+ request.headers.get("X-BARE-PASSWORD") == f"password {self.password}"
17
+ or request.headers.get("Authorization") == f"Bearer {self.password}"
18
+ ):
19
+ return await call_next(request)
20
+
21
+ return JSONResponse(
22
+ content={"detail": "Unauthorized"},
23
+ status_code=401,
24
+ )
@@ -0,0 +1,25 @@
1
+ from starlette.middleware.base import BaseHTTPMiddleware
2
+
3
+
4
+ class ProfilerContextMiddleware(BaseHTTPMiddleware):
5
+ """Middleware to set context if using profiler. Currently just uses google-cloud-profiler."""
6
+
7
+ async def dispatch(self, request, call_next):
8
+ ctx = None
9
+ if request.url.path in {"/v1/health", "/v1/health/"}:
10
+ return await call_next(request)
11
+ try:
12
+ labels = {
13
+ "method": request.method,
14
+ "path": request.url.path,
15
+ "endpoint": request.url.path,
16
+ }
17
+ import googlecloudprofiler
18
+
19
+ ctx = googlecloudprofiler.context.set_labels(**labels)
20
+ except:
21
+ return await call_next(request)
22
+ if ctx:
23
+ with ctx:
24
+ return await call_next(request)
25
+ return await call_next(request)
@@ -22,6 +22,7 @@ async def list_blocks(
22
22
  name: Optional[str] = Query(None, description="Name of the block"),
23
23
  identity_id: Optional[str] = Query(None, description="Search agents by identifier id"),
24
24
  identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"),
25
+ project_id: Optional[str] = Query(None, description="Search blocks by project id"),
25
26
  limit: Optional[int] = Query(50, description="Number of blocks to return"),
26
27
  server: SyncServer = Depends(get_letta_server),
27
28
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
@@ -34,6 +35,7 @@ async def list_blocks(
34
35
  template_name=name,
35
36
  identity_id=identity_id,
36
37
  identifier_keys=identifier_keys,
38
+ project_id=project_id,
37
39
  limit=limit,
38
40
  )
39
41
 
@@ -31,12 +31,12 @@ def list_groups(
31
31
  """
32
32
  actor = server.user_manager.get_user_or_default(user_id=actor_id)
33
33
  return server.group_manager.list_groups(
34
+ actor=actor,
34
35
  project_id=project_id,
35
36
  manager_type=manager_type,
36
37
  before=before,
37
38
  after=after,
38
39
  limit=limit,
39
- actor=actor,
40
40
  )
41
41
 
42
42
 
@@ -2,6 +2,7 @@ import asyncio
2
2
  import mimetypes
3
3
  import os
4
4
  import tempfile
5
+ from datetime import datetime, timedelta, timezone
5
6
  from pathlib import Path
6
7
  from typing import List, Optional
7
8
 
@@ -393,6 +394,31 @@ async def get_file_metadata(
393
394
  if file_metadata.source_id != source_id:
394
395
  raise HTTPException(status_code=404, detail=f"File with id={file_id} not found in source {source_id}.")
395
396
 
397
+ # Check for timeout if status is not terminal
398
+ if not file_metadata.processing_status.is_terminal_state():
399
+ if file_metadata.created_at:
400
+ # Handle timezone differences between PostgreSQL (timezone-aware) and SQLite (timezone-naive)
401
+ if settings.letta_pg_uri_no_default:
402
+ # PostgreSQL: both datetimes are timezone-aware
403
+ timeout_threshold = datetime.now(timezone.utc) - timedelta(minutes=settings.file_processing_timeout_minutes)
404
+ file_created_at = file_metadata.created_at
405
+ else:
406
+ # SQLite: both datetimes should be timezone-naive
407
+ timeout_threshold = datetime.utcnow() - timedelta(minutes=settings.file_processing_timeout_minutes)
408
+ file_created_at = file_metadata.created_at
409
+
410
+ if file_created_at < timeout_threshold:
411
+ # Move file to error status with timeout message
412
+ timeout_message = settings.file_processing_timeout_error_message.format(settings.file_processing_timeout_minutes)
413
+ try:
414
+ file_metadata = await server.file_manager.update_file_status(
415
+ file_id=file_metadata.id, actor=actor, processing_status=FileProcessingStatus.ERROR, error_message=timeout_message
416
+ )
417
+ except ValueError as e:
418
+ # state transition was blocked - log it but don't fail the request
419
+ logger.warning(f"Could not update file to timeout error state: {str(e)}")
420
+ # continue with existing file_metadata
421
+
396
422
  if should_use_pinecone() and file_metadata.processing_status == FileProcessingStatus.EMBEDDING:
397
423
  ids = await list_pinecone_index_for_files(file_id=file_id, actor=actor)
398
424
  logger.info(