letta-nightly 0.9.1.dev20250731104458__py3-none-any.whl → 0.10.0.dev20250801010504__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 (76) hide show
  1. letta/__init__.py +2 -1
  2. letta/agent.py +1 -1
  3. letta/agents/base_agent.py +2 -2
  4. letta/agents/letta_agent.py +22 -8
  5. letta/agents/letta_agent_batch.py +2 -2
  6. letta/agents/voice_agent.py +2 -2
  7. letta/client/client.py +0 -11
  8. letta/errors.py +11 -0
  9. letta/functions/function_sets/builtin.py +3 -7
  10. letta/functions/mcp_client/types.py +107 -1
  11. letta/helpers/reasoning_helper.py +48 -0
  12. letta/helpers/tool_execution_helper.py +2 -65
  13. letta/interfaces/openai_streaming_interface.py +38 -2
  14. letta/llm_api/anthropic_client.py +1 -5
  15. letta/llm_api/google_vertex_client.py +1 -1
  16. letta/llm_api/llm_client.py +1 -1
  17. letta/llm_api/openai_client.py +2 -0
  18. letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +3 -2
  19. letta/orm/agent.py +5 -0
  20. letta/orm/enums.py +0 -1
  21. letta/orm/file.py +0 -1
  22. letta/orm/files_agents.py +9 -9
  23. letta/orm/sandbox_config.py +1 -1
  24. letta/orm/sqlite_functions.py +15 -13
  25. letta/prompts/system/memgpt_generate_tool.txt +139 -0
  26. letta/schemas/agent.py +15 -1
  27. letta/schemas/enums.py +6 -0
  28. letta/schemas/file.py +3 -3
  29. letta/schemas/letta_ping.py +28 -0
  30. letta/schemas/letta_request.py +9 -0
  31. letta/schemas/letta_stop_reason.py +25 -0
  32. letta/schemas/llm_config.py +1 -0
  33. letta/schemas/mcp.py +16 -3
  34. letta/schemas/memory.py +5 -0
  35. letta/schemas/providers/lmstudio.py +7 -0
  36. letta/schemas/providers/ollama.py +11 -8
  37. letta/schemas/sandbox_config.py +17 -7
  38. letta/server/rest_api/app.py +2 -0
  39. letta/server/rest_api/routers/v1/agents.py +93 -30
  40. letta/server/rest_api/routers/v1/blocks.py +52 -0
  41. letta/server/rest_api/routers/v1/sandbox_configs.py +2 -1
  42. letta/server/rest_api/routers/v1/tools.py +43 -101
  43. letta/server/rest_api/streaming_response.py +121 -9
  44. letta/server/server.py +6 -10
  45. letta/services/agent_manager.py +41 -4
  46. letta/services/block_manager.py +63 -1
  47. letta/services/file_processor/chunker/line_chunker.py +20 -19
  48. letta/services/file_processor/file_processor.py +0 -2
  49. letta/services/file_processor/file_types.py +1 -2
  50. letta/services/files_agents_manager.py +46 -6
  51. letta/services/helpers/agent_manager_helper.py +185 -13
  52. letta/services/job_manager.py +4 -4
  53. letta/services/mcp/oauth_utils.py +6 -150
  54. letta/services/mcp_manager.py +120 -2
  55. letta/services/sandbox_config_manager.py +3 -5
  56. letta/services/tool_executor/builtin_tool_executor.py +13 -18
  57. letta/services/tool_executor/files_tool_executor.py +31 -27
  58. letta/services/tool_executor/mcp_tool_executor.py +10 -1
  59. letta/services/tool_executor/{tool_executor.py → sandbox_tool_executor.py} +14 -2
  60. letta/services/tool_executor/tool_execution_manager.py +1 -1
  61. letta/services/tool_executor/tool_execution_sandbox.py +2 -1
  62. letta/services/tool_manager.py +59 -21
  63. letta/services/tool_sandbox/base.py +18 -2
  64. letta/services/tool_sandbox/e2b_sandbox.py +5 -35
  65. letta/services/tool_sandbox/local_sandbox.py +5 -22
  66. letta/services/tool_sandbox/modal_sandbox.py +205 -0
  67. letta/settings.py +27 -8
  68. letta/system.py +1 -4
  69. letta/templates/template_helper.py +5 -0
  70. letta/utils.py +14 -2
  71. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/METADATA +7 -3
  72. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/RECORD +75 -72
  73. letta/orm/__all__.py +0 -15
  74. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/LICENSE +0 -0
  75. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/WHEEL +0 -0
  76. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/entry_points.txt +0 -0
@@ -1,21 +1,17 @@
1
1
  import hashlib
2
2
  import json
3
- from enum import Enum
4
3
  from typing import Any, Dict, List, Literal, Optional, Union
5
4
 
6
5
  from pydantic import BaseModel, Field, model_validator
7
6
 
8
7
  from letta.constants import LETTA_TOOL_EXECUTION_DIR
9
8
  from letta.schemas.agent import AgentState
9
+ from letta.schemas.enums import SandboxType
10
10
  from letta.schemas.letta_base import LettaBase, OrmMetadataBase
11
11
  from letta.schemas.pip_requirement import PipRequirement
12
12
  from letta.settings import tool_settings
13
13
 
14
-
15
14
  # Sandbox Config
16
- class SandboxType(str, Enum):
17
- E2B = "e2b"
18
- LOCAL = "local"
19
15
 
20
16
 
21
17
  class SandboxRunResult(BaseModel):
@@ -83,6 +79,15 @@ class E2BSandboxConfig(BaseModel):
83
79
  return data
84
80
 
85
81
 
82
+ class ModalSandboxConfig(BaseModel):
83
+ timeout: int = Field(5 * 60, description="Time limit for the sandbox (in seconds).")
84
+ pip_requirements: Optional[List[str]] = Field(None, description="A list of pip packages to install in the Modal sandbox")
85
+
86
+ @property
87
+ def type(self) -> "SandboxType":
88
+ return SandboxType.MODAL
89
+
90
+
86
91
  class SandboxConfigBase(OrmMetadataBase):
87
92
  __id_prefix__ = "sandbox"
88
93
 
@@ -99,6 +104,9 @@ class SandboxConfig(SandboxConfigBase):
99
104
  def get_local_config(self) -> LocalSandboxConfig:
100
105
  return LocalSandboxConfig(**self.config)
101
106
 
107
+ def get_modal_config(self) -> ModalSandboxConfig:
108
+ return ModalSandboxConfig(**self.config)
109
+
102
110
  def fingerprint(self) -> str:
103
111
  # Only take into account type, org_id, and the config items
104
112
  # Canonicalize input data into JSON with sorted keys
@@ -120,10 +128,12 @@ class SandboxConfig(SandboxConfigBase):
120
128
 
121
129
 
122
130
  class SandboxConfigCreate(LettaBase):
123
- config: Union[LocalSandboxConfig, E2BSandboxConfig] = Field(..., description="The configuration for the sandbox.")
131
+ config: Union[LocalSandboxConfig, E2BSandboxConfig, ModalSandboxConfig] = Field(..., description="The configuration for the sandbox.")
124
132
 
125
133
 
126
134
  class SandboxConfigUpdate(LettaBase):
127
135
  """Pydantic model for updating SandboxConfig fields."""
128
136
 
129
- config: Union[LocalSandboxConfig, E2BSandboxConfig] = Field(None, description="The JSON configuration data for the sandbox.")
137
+ config: Union[LocalSandboxConfig, E2BSandboxConfig, ModalSandboxConfig] = Field(
138
+ None, description="The JSON configuration data for the sandbox."
139
+ )
@@ -28,6 +28,7 @@ from letta.schemas.letta_message_content import (
28
28
  create_letta_message_content_union_schema,
29
29
  create_letta_user_message_content_union_schema,
30
30
  )
31
+ from letta.schemas.letta_ping import create_letta_ping_schema
31
32
  from letta.server.constants import REST_DEFAULT_PORT
32
33
  from letta.server.db import db_registry
33
34
 
@@ -67,6 +68,7 @@ def generate_openapi_schema(app: FastAPI):
67
68
  letta_docs["components"]["schemas"]["LettaMessageContentUnion"] = create_letta_message_content_union_schema()
68
69
  letta_docs["components"]["schemas"]["LettaAssistantMessageContentUnion"] = create_letta_assistant_message_content_union_schema()
69
70
  letta_docs["components"]["schemas"]["LettaUserMessageContentUnion"] = create_letta_user_message_content_union_schema()
71
+ letta_docs["components"]["schemas"]["LettaPing"] = create_letta_ping_schema()
70
72
 
71
73
  # Update the app's schema with our modified version
72
74
  app.openapi_schema = letta_docs
@@ -41,7 +41,7 @@ from letta.server.server import SyncServer
41
41
  from letta.services.summarizer.enums import SummarizationMode
42
42
  from letta.services.telemetry_manager import NoopTelemetryManager
43
43
  from letta.settings import settings
44
- from letta.utils import safe_create_task
44
+ from letta.utils import safe_create_task, truncate_file_visible_content
45
45
 
46
46
  # These can be forward refs, but because Fastapi needs them at runtime the must be imported normally
47
47
 
@@ -65,7 +65,7 @@ async def list_agents(
65
65
  after: str | None = Query(None, description="Cursor for pagination"),
66
66
  limit: int | None = Query(50, description="Limit for pagination"),
67
67
  query_text: str | None = Query(None, description="Search agents by name"),
68
- project_id: str | None = Query(None, description="Search agents by project ID"),
68
+ project_id: str | None = Query(None, description="Search agents by project ID - this will default to your default project on cloud"),
69
69
  template_id: str | None = Query(None, description="Search agents by template ID"),
70
70
  base_template_id: str | None = Query(None, description="Search agents by base template ID"),
71
71
  identity_id: str | None = Query(None, description="Search agents by identity ID"),
@@ -86,6 +86,11 @@ async def list_agents(
86
86
  "created_at",
87
87
  description="Field to sort by. Options: 'created_at' (default), 'last_run_completion'",
88
88
  ),
89
+ show_hidden_agents: bool | None = Query(
90
+ False,
91
+ include_in_schema=False,
92
+ description="If set to True, include agents marked as hidden in the results.",
93
+ ),
89
94
  ):
90
95
  """
91
96
  List all agents associated with a given user.
@@ -115,6 +120,7 @@ async def list_agents(
115
120
  include_relationships=include_relationships,
116
121
  ascending=ascending,
117
122
  sort_by=sort_by,
123
+ show_hidden_agents=show_hidden_agents,
118
124
  )
119
125
 
120
126
 
@@ -478,14 +484,23 @@ async def open_file(
478
484
  if not file_metadata:
479
485
  raise HTTPException(status_code=404, detail=f"File with id={file_id} not found")
480
486
 
487
+ # Process file content with line numbers using LineChunker
488
+ from letta.services.file_processor.chunker.line_chunker import LineChunker
489
+
490
+ content_lines = LineChunker().chunk_text(file_metadata=file_metadata, validate_range=False)
491
+ visible_content = "\n".join(content_lines)
492
+
493
+ # Truncate if needed
494
+ visible_content = truncate_file_visible_content(visible_content, True, per_file_view_window_char_limit)
495
+
481
496
  # Use enforce_max_open_files_and_open for efficient LRU handling
482
- closed_files, was_already_open = await server.file_agent_manager.enforce_max_open_files_and_open(
497
+ closed_files, was_already_open, _ = await server.file_agent_manager.enforce_max_open_files_and_open(
483
498
  agent_id=agent_id,
484
499
  file_id=file_id,
485
500
  file_name=file_metadata.file_name,
486
501
  source_id=file_metadata.source_id,
487
502
  actor=actor,
488
- visible_content=file_metadata.content[:per_file_view_window_char_limit] if file_metadata.content else "",
503
+ visible_content=visible_content,
489
504
  max_files_open=max_files_open,
490
505
  )
491
506
 
@@ -850,7 +865,15 @@ async def send_message(
850
865
  # TODO: This is redundant, remove soon
851
866
  agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"])
852
867
  agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
853
- model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "together", "google_ai", "google_vertex", "bedrock"]
868
+ model_compatible = agent.llm_config.model_endpoint_type in [
869
+ "anthropic",
870
+ "openai",
871
+ "together",
872
+ "google_ai",
873
+ "google_vertex",
874
+ "bedrock",
875
+ "ollama",
876
+ ]
854
877
 
855
878
  # Create a new run for execution tracking
856
879
  if settings.track_agent_run:
@@ -984,7 +1007,15 @@ async def send_message_streaming(
984
1007
  # TODO: This is redundant, remove soon
985
1008
  agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"])
986
1009
  agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
987
- model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "together", "google_ai", "google_vertex", "bedrock"]
1010
+ model_compatible = agent.llm_config.model_endpoint_type in [
1011
+ "anthropic",
1012
+ "openai",
1013
+ "together",
1014
+ "google_ai",
1015
+ "google_vertex",
1016
+ "bedrock",
1017
+ "ollama",
1018
+ ]
988
1019
  model_compatible_token_streaming = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "bedrock"]
989
1020
  not_letta_endpoint = agent.llm_config.model_endpoint != LETTA_MODEL_ENDPOINT
990
1021
 
@@ -1052,28 +1083,42 @@ async def send_message_streaming(
1052
1083
  else SummarizationMode.PARTIAL_EVICT_MESSAGE_BUFFER
1053
1084
  ),
1054
1085
  )
1055
- from letta.server.rest_api.streaming_response import StreamingResponseWithStatusCode
1086
+ from letta.server.rest_api.streaming_response import StreamingResponseWithStatusCode, add_keepalive_to_stream
1056
1087
 
1057
1088
  if request.stream_tokens and model_compatible_token_streaming and not_letta_endpoint:
1089
+ raw_stream = agent_loop.step_stream(
1090
+ input_messages=request.messages,
1091
+ max_steps=request.max_steps,
1092
+ use_assistant_message=request.use_assistant_message,
1093
+ request_start_timestamp_ns=request_start_timestamp_ns,
1094
+ include_return_message_types=request.include_return_message_types,
1095
+ )
1096
+ # Conditionally wrap with keepalive based on request parameter
1097
+ if request.include_pings and settings.enable_keepalive:
1098
+ stream = add_keepalive_to_stream(raw_stream, keepalive_interval=settings.keepalive_interval)
1099
+ else:
1100
+ stream = raw_stream
1101
+
1058
1102
  result = StreamingResponseWithStatusCode(
1059
- agent_loop.step_stream(
1060
- input_messages=request.messages,
1061
- max_steps=request.max_steps,
1062
- use_assistant_message=request.use_assistant_message,
1063
- request_start_timestamp_ns=request_start_timestamp_ns,
1064
- include_return_message_types=request.include_return_message_types,
1065
- ),
1103
+ stream,
1066
1104
  media_type="text/event-stream",
1067
1105
  )
1068
1106
  else:
1107
+ raw_stream = agent_loop.step_stream_no_tokens(
1108
+ request.messages,
1109
+ max_steps=request.max_steps,
1110
+ use_assistant_message=request.use_assistant_message,
1111
+ request_start_timestamp_ns=request_start_timestamp_ns,
1112
+ include_return_message_types=request.include_return_message_types,
1113
+ )
1114
+ # Conditionally wrap with keepalive based on request parameter
1115
+ if request.include_pings and settings.enable_keepalive:
1116
+ stream = add_keepalive_to_stream(raw_stream, keepalive_interval=settings.keepalive_interval)
1117
+ else:
1118
+ stream = raw_stream
1119
+
1069
1120
  result = StreamingResponseWithStatusCode(
1070
- agent_loop.step_stream_no_tokens(
1071
- request.messages,
1072
- max_steps=request.max_steps,
1073
- use_assistant_message=request.use_assistant_message,
1074
- request_start_timestamp_ns=request_start_timestamp_ns,
1075
- include_return_message_types=request.include_return_message_types,
1076
- ),
1121
+ stream,
1077
1122
  media_type="text/event-stream",
1078
1123
  )
1079
1124
  else:
@@ -1165,6 +1210,7 @@ async def _process_message_background(
1165
1210
  "google_ai",
1166
1211
  "google_vertex",
1167
1212
  "bedrock",
1213
+ "ollama",
1168
1214
  ]
1169
1215
  if agent_eligible and model_compatible:
1170
1216
  if agent.enable_sleeptime and agent.agent_type != AgentType.voice_convo_agent:
@@ -1344,7 +1390,15 @@ async def preview_raw_payload(
1344
1390
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
1345
1391
  agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"])
1346
1392
  agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
1347
- model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "together", "google_ai", "google_vertex", "bedrock"]
1393
+ model_compatible = agent.llm_config.model_endpoint_type in [
1394
+ "anthropic",
1395
+ "openai",
1396
+ "together",
1397
+ "google_ai",
1398
+ "google_vertex",
1399
+ "bedrock",
1400
+ "ollama",
1401
+ ]
1348
1402
 
1349
1403
  if agent_eligible and model_compatible:
1350
1404
  if agent.enable_sleeptime:
@@ -1386,7 +1440,7 @@ async def preview_raw_payload(
1386
1440
  )
1387
1441
 
1388
1442
 
1389
- @router.post("/{agent_id}/summarize", response_model=AgentState, operation_id="summarize_agent_conversation")
1443
+ @router.post("/{agent_id}/summarize", status_code=204, operation_id="summarize_agent_conversation")
1390
1444
  async def summarize_agent_conversation(
1391
1445
  agent_id: str,
1392
1446
  request_obj: Request, # FastAPI Request
@@ -1404,7 +1458,15 @@ async def summarize_agent_conversation(
1404
1458
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
1405
1459
  agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"])
1406
1460
  agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
1407
- model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "together", "google_ai", "google_vertex", "bedrock"]
1461
+ model_compatible = agent.llm_config.model_endpoint_type in [
1462
+ "anthropic",
1463
+ "openai",
1464
+ "together",
1465
+ "google_ai",
1466
+ "google_vertex",
1467
+ "bedrock",
1468
+ "ollama",
1469
+ ]
1408
1470
 
1409
1471
  if agent_eligible and model_compatible:
1410
1472
  agent = LettaAgent(
@@ -1419,9 +1481,10 @@ async def summarize_agent_conversation(
1419
1481
  telemetry_manager=server.telemetry_manager if settings.llm_api_logging else NoopTelemetryManager(),
1420
1482
  message_buffer_min=max_message_length,
1421
1483
  )
1422
- return await agent.summarize_conversation_history()
1423
-
1424
- raise HTTPException(
1425
- status_code=status.HTTP_403_FORBIDDEN,
1426
- detail="Summarization is not currently supported for this agent configuration. Please contact Letta support.",
1427
- )
1484
+ await agent.summarize_conversation_history()
1485
+ # Summarization completed, return 204 No Content
1486
+ else:
1487
+ raise HTTPException(
1488
+ status_code=status.HTTP_403_FORBIDDEN,
1489
+ detail="Summarization is not currently supported for this agent configuration. Please contact Letta support.",
1490
+ )
@@ -24,6 +24,50 @@ async def list_blocks(
24
24
  identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"),
25
25
  project_id: Optional[str] = Query(None, description="Search blocks by project id"),
26
26
  limit: Optional[int] = Query(50, description="Number of blocks to return"),
27
+ before: Optional[str] = Query(
28
+ None,
29
+ description="Cursor for pagination. If provided, returns blocks before this cursor.",
30
+ ),
31
+ after: Optional[str] = Query(
32
+ None,
33
+ description="Cursor for pagination. If provided, returns blocks after this cursor.",
34
+ ),
35
+ label_search: Optional[str] = Query(
36
+ None,
37
+ description=("Search blocks by label. If provided, returns blocks that match this label. " "This is a full-text search on labels."),
38
+ ),
39
+ description_search: Optional[str] = Query(
40
+ None,
41
+ description=(
42
+ "Search blocks by description. If provided, returns blocks that match this description. "
43
+ "This is a full-text search on block descriptions."
44
+ ),
45
+ ),
46
+ value_search: Optional[str] = Query(
47
+ None,
48
+ description=("Search blocks by value. If provided, returns blocks that match this value."),
49
+ ),
50
+ connected_to_agents_count_gt: Optional[int] = Query(
51
+ None,
52
+ description=(
53
+ "Filter blocks by the number of connected agents. "
54
+ "If provided, returns blocks that have more than this number of connected agents."
55
+ ),
56
+ ),
57
+ connected_to_agents_count_lt: Optional[int] = Query(
58
+ None,
59
+ description=(
60
+ "Filter blocks by the number of connected agents. "
61
+ "If provided, returns blocks that have less than this number of connected agents."
62
+ ),
63
+ ),
64
+ connected_to_agents_count_eq: Optional[List[int]] = Query(
65
+ None,
66
+ description=(
67
+ "Filter blocks by the exact number of connected agents. "
68
+ "If provided, returns blocks that have exactly this number of connected agents."
69
+ ),
70
+ ),
27
71
  server: SyncServer = Depends(get_letta_server),
28
72
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
29
73
  ):
@@ -32,11 +76,19 @@ async def list_blocks(
32
76
  actor=actor,
33
77
  label=label,
34
78
  is_template=templates_only,
79
+ value_search=value_search,
80
+ label_search=label_search,
81
+ description_search=description_search,
35
82
  template_name=name,
36
83
  identity_id=identity_id,
37
84
  identifier_keys=identifier_keys,
38
85
  project_id=project_id,
86
+ before=before,
87
+ connected_to_agents_count_gt=connected_to_agents_count_gt,
88
+ connected_to_agents_count_lt=connected_to_agents_count_lt,
89
+ connected_to_agents_count_eq=connected_to_agents_count_eq,
39
90
  limit=limit,
91
+ after=after,
40
92
  )
41
93
 
42
94
 
@@ -5,11 +5,12 @@ from typing import List, Optional
5
5
  from fastapi import APIRouter, Depends, HTTPException, Query
6
6
 
7
7
  from letta.log import get_logger
8
+ from letta.schemas.enums import SandboxType
8
9
  from letta.schemas.environment_variables import SandboxEnvironmentVariable as PydanticEnvVar
9
10
  from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate, SandboxEnvironmentVariableUpdate
10
11
  from letta.schemas.sandbox_config import LocalSandboxConfig
11
12
  from letta.schemas.sandbox_config import SandboxConfig as PydanticSandboxConfig
12
- from letta.schemas.sandbox_config import SandboxConfigCreate, SandboxConfigUpdate, SandboxType
13
+ from letta.schemas.sandbox_config import SandboxConfigCreate, SandboxConfigUpdate
13
14
  from letta.server.rest_api.utils import get_letta_server, get_user_id
14
15
  from letta.server.server import SyncServer
15
16
  from letta.services.helpers.tool_execution_helper import create_venv_for_local_sandbox, install_pip_requirements_for_sandbox
@@ -1,4 +1,3 @@
1
- import asyncio
2
1
  import json
3
2
  from collections.abc import AsyncGenerator
4
3
  from typing import Any, Dict, List, Optional, Union
@@ -12,12 +11,12 @@ from composio.exceptions import (
12
11
  EnumMetadataNotFound,
13
12
  EnumStringNotFound,
14
13
  )
15
- from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
16
- from fastapi.responses import HTMLResponse
14
+ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request
15
+ from httpx import HTTPStatusError
17
16
  from pydantic import BaseModel, Field
18
17
  from starlette.responses import StreamingResponse
19
18
 
20
- from letta.errors import LettaToolCreateError
19
+ from letta.errors import LettaToolCreateError, LettaToolNameConflictError
21
20
  from letta.functions.functions import derive_openai_json_schema
22
21
  from letta.functions.mcp_client.exceptions import MCPTimeoutError
23
22
  from letta.functions.mcp_client.types import MCPTool, SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig
@@ -27,22 +26,18 @@ from letta.llm_api.llm_client import LLMClient
27
26
  from letta.log import get_logger
28
27
  from letta.orm.errors import UniqueConstraintViolationError
29
28
  from letta.orm.mcp_oauth import OAuthSessionStatus
29
+ from letta.prompts.gpt_system import get_system_text
30
30
  from letta.schemas.enums import MessageRole
31
31
  from letta.schemas.letta_message import ToolReturnMessage
32
32
  from letta.schemas.letta_message_content import TextContent
33
- from letta.schemas.mcp import MCPOAuthSessionCreate, UpdateSSEMCPServer, UpdateStdioMCPServer, UpdateStreamableHTTPMCPServer
33
+ from letta.schemas.mcp import UpdateSSEMCPServer, UpdateStdioMCPServer, UpdateStreamableHTTPMCPServer
34
34
  from letta.schemas.message import Message
35
+ from letta.schemas.pip_requirement import PipRequirement
35
36
  from letta.schemas.tool import Tool, ToolCreate, ToolRunFromSource, ToolUpdate
36
37
  from letta.server.rest_api.streaming_response import StreamingResponseWithStatusCode
37
38
  from letta.server.rest_api.utils import get_letta_server
38
39
  from letta.server.server import SyncServer
39
- from letta.services.mcp.oauth_utils import (
40
- MCPOAuthSession,
41
- create_oauth_provider,
42
- drill_down_exception,
43
- get_oauth_success_html,
44
- oauth_stream_event,
45
- )
40
+ from letta.services.mcp.oauth_utils import MCPOAuthSession, drill_down_exception, oauth_stream_event
46
41
  from letta.services.mcp.stdio_client import AsyncStdioMCPClient
47
42
  from letta.services.mcp.types import OauthStreamEvent
48
43
  from letta.settings import tool_settings
@@ -196,6 +191,10 @@ async def modify_tool(
196
191
  try:
197
192
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
198
193
  return await server.tool_manager.update_tool_by_id_async(tool_id=tool_id, tool_update=request, actor=actor)
194
+ except LettaToolNameConflictError as e:
195
+ # HTTP 409 == Conflict
196
+ print(f"Tool name conflict during update: {e}")
197
+ raise HTTPException(status_code=409, detail=str(e))
199
198
  except LettaToolCreateError as e:
200
199
  # HTTP 400 == Bad Request
201
200
  print(f"Error occurred during tool update: {e}")
@@ -394,7 +393,7 @@ async def list_mcp_servers(server: SyncServer = Depends(get_letta_server), user_
394
393
  else:
395
394
  actor = await server.user_manager.get_actor_or_default_async(actor_id=user_id)
396
395
  mcp_servers = await server.mcp_manager.list_mcp_servers(actor=actor)
397
- return {server.server_name: server.to_config() for server in mcp_servers}
396
+ return {server.server_name: server.to_config(resolve_variables=False) for server in mcp_servers}
398
397
 
399
398
 
400
399
  # NOTE: async because the MCP client/session calls are async
@@ -634,11 +633,12 @@ async def test_mcp_server(
634
633
  ):
635
634
  """
636
635
  Test connection to an MCP server without adding it.
637
- Returns the list of available tools if successful, or OAuth information if OAuth is required.
636
+ Returns the list of available tools if successful.
638
637
  """
639
638
  client = None
640
639
  try:
641
640
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
641
+ request.resolve_environment_variables()
642
642
  client = await server.mcp_manager.get_mcp_client(request, actor)
643
643
 
644
644
  await client.connect_to_server()
@@ -697,6 +697,7 @@ async def connect_mcp_server(
697
697
  request: Union[StdioServerConfig, SSEServerConfig, StreamableHTTPServerConfig] = Body(...),
698
698
  server: SyncServer = Depends(get_letta_server),
699
699
  actor_id: Optional[str] = Header(None, alias="user_id"),
700
+ http_request: Request = None,
700
701
  ) -> StreamingResponse:
701
702
  """
702
703
  Connect to an MCP server with support for OAuth via SSE.
@@ -705,12 +706,11 @@ async def connect_mcp_server(
705
706
 
706
707
  async def oauth_stream_generator(
707
708
  request: Union[StdioServerConfig, SSEServerConfig, StreamableHTTPServerConfig],
709
+ http_request: Request,
708
710
  ) -> AsyncGenerator[str, None]:
709
711
  client = None
710
- oauth_provider = None
711
- temp_client = None
712
- connect_task = None
713
712
 
713
+ oauth_flow_attempted = False
714
714
  try:
715
715
  # Acknolwedge connection attempt
716
716
  yield oauth_stream_event(OauthStreamEvent.CONNECTION_ATTEMPT, server_name=request.server_name)
@@ -719,6 +719,7 @@ async def connect_mcp_server(
719
719
 
720
720
  # Create MCP client with respective transport type
721
721
  try:
722
+ request.resolve_environment_variables()
722
723
  client = await server.mcp_manager.get_mcp_client(request, actor)
723
724
  except ValueError as e:
724
725
  yield oauth_stream_event(OauthStreamEvent.ERROR, message=str(e))
@@ -741,97 +742,35 @@ async def connect_mcp_server(
741
742
  except Exception as e:
742
743
  yield oauth_stream_event(OauthStreamEvent.ERROR, message=f"Connection failed: {str(e)}")
743
744
  return
744
-
745
- # OAuth required, yield state to client to prepare to handle authorization URL
746
- yield oauth_stream_event(OauthStreamEvent.OAUTH_REQUIRED, message="OAuth authentication required")
747
-
748
- # Create OAuth session to persist the state of the OAuth flow
749
- session_create = MCPOAuthSessionCreate(
750
- server_url=request.server_url,
751
- server_name=request.server_name,
752
- user_id=actor.id,
753
- organization_id=actor.organization_id,
754
- )
755
- oauth_session = await server.mcp_manager.create_oauth_session(session_create, actor)
756
- session_id = oauth_session.id
757
-
758
- # Create OAuth provider for the instance of the stream connection
759
- # Note: Using the correct API path for the callback
760
- # do not edit this this is the correct url
761
- redirect_uri = f"http://localhost:8283/v1/tools/mcp/oauth/callback/{session_id}"
762
- oauth_provider = await create_oauth_provider(session_id, request.server_url, redirect_uri, server.mcp_manager, actor)
763
-
764
- # Get authorization URL by triggering OAuth flow
765
- temp_client = None
766
- try:
767
- temp_client = await server.mcp_manager.get_mcp_client(request, actor, oauth_provider)
768
-
769
- # Run connect_to_server in background to avoid blocking
770
- # This will trigger the OAuth flow and the redirect_handler will save the authorization URL to database
771
- connect_task = asyncio.create_task(temp_client.connect_to_server())
772
-
773
- # Give the OAuth flow time to trigger and save the URL
774
- await asyncio.sleep(1.0)
775
-
776
- # Fetch the authorization URL from database and yield state to client to proceed with handling authorization URL
777
- auth_session = await server.mcp_manager.get_oauth_session_by_id(session_id, actor)
778
- if auth_session and auth_session.authorization_url:
779
- yield oauth_stream_event(OauthStreamEvent.AUTHORIZATION_URL, url=auth_session.authorization_url, session_id=session_id)
780
-
781
- except Exception as e:
782
- logger.error(f"Error triggering OAuth flow: {e}")
783
- yield oauth_stream_event(OauthStreamEvent.ERROR, message=f"Failed to trigger OAuth: {str(e)}")
784
-
785
- # Clean up active resources
786
- if connect_task and not connect_task.done():
787
- connect_task.cancel()
788
- try:
789
- await connect_task
790
- except asyncio.CancelledError:
791
- pass
792
- if temp_client:
745
+ finally:
746
+ if client:
793
747
  try:
794
- await temp_client.cleanup()
795
- except Exception as cleanup_error:
796
- logger.warning(f"Error during temp MCP client cleanup: {cleanup_error}")
797
- return
798
-
799
- # Wait for user authorization (with timeout), client should render loading state until user completes the flow and /mcp/oauth/callback/{session_id} is hit
800
- yield oauth_stream_event(OauthStreamEvent.WAITING_FOR_AUTH, message="Waiting for user authorization...")
801
-
802
- # Callback handler will poll for authorization code and state and update the OAuth session
803
- await connect_task
804
-
805
- tools = await temp_client.list_tools(serialize=True)
806
-
807
- yield oauth_stream_event(OauthStreamEvent.SUCCESS, tools=tools)
748
+ await client.cleanup()
749
+ # This is a workaround to catch the expected 401 Unauthorized from the official MCP SDK, see their streamable_http.py
750
+ # For SSE transport types, we catch the ConnectionError above, but Streamable HTTP doesn't bubble up the exception
751
+ except* HTTPStatusError:
752
+ oauth_flow_attempted = True
753
+ async for event in server.mcp_manager.handle_oauth_flow(request=request, actor=actor, http_request=http_request):
754
+ yield event
755
+
756
+ # Failsafe to make sure we don't try to handle OAuth flow twice
757
+ if not oauth_flow_attempted:
758
+ async for event in server.mcp_manager.handle_oauth_flow(request=request, actor=actor, http_request=http_request):
759
+ yield event
808
760
  return
809
761
  except Exception as e:
810
762
  detailed_error = drill_down_exception(e)
811
763
  logger.error(f"Error in OAuth stream:\n{detailed_error}")
812
764
  yield oauth_stream_event(OauthStreamEvent.ERROR, message=f"Internal error: {detailed_error}")
765
+
813
766
  finally:
814
- if connect_task and not connect_task.done():
815
- connect_task.cancel()
816
- try:
817
- await connect_task
818
- except asyncio.CancelledError:
819
- pass
820
767
  if client:
821
768
  try:
822
769
  await client.cleanup()
823
770
  except Exception as cleanup_error:
824
- detailed_error = drill_down_exception(cleanup_error)
825
- logger.warning(f"Error during MCP client cleanup: {detailed_error}")
826
- if temp_client:
827
- try:
828
- await temp_client.cleanup()
829
- except Exception as cleanup_error:
830
- # TODO: @jnjpng fix async cancel scope issue
831
- # detailed_error = drill_down_exception(cleanup_error)
832
- logger.warning(f"Aysnc cleanup confict during temp MCP client cleanup: {cleanup_error}")
771
+ logger.warning(f"Error during temp MCP client cleanup: {cleanup_error}")
833
772
 
834
- return StreamingResponseWithStatusCode(oauth_stream_generator(request), media_type="text/event-stream")
773
+ return StreamingResponseWithStatusCode(oauth_stream_generator(request, http_request), media_type="text/event-stream")
835
774
 
836
775
 
837
776
  class CodeInput(BaseModel):
@@ -856,7 +795,7 @@ async def generate_json_schema(
856
795
 
857
796
 
858
797
  # TODO: @jnjpng need to route this through cloud API for production
859
- @router.get("/mcp/oauth/callback/{session_id}", operation_id="mcp_oauth_callback", response_class=HTMLResponse)
798
+ @router.get("/mcp/oauth/callback/{session_id}", operation_id="mcp_oauth_callback")
860
799
  async def mcp_oauth_callback(
861
800
  session_id: str,
862
801
  code: Optional[str] = Query(None, description="OAuth authorization code"),
@@ -869,7 +808,6 @@ async def mcp_oauth_callback(
869
808
  """
870
809
  try:
871
810
  oauth_session = MCPOAuthSession(session_id)
872
-
873
811
  if error:
874
812
  error_msg = f"OAuth error: {error}"
875
813
  if error_description:
@@ -887,7 +825,7 @@ async def mcp_oauth_callback(
887
825
  await oauth_session.update_session_status(OAuthSessionStatus.ERROR)
888
826
  return {"status": "error", "message": "Invalid state parameter"}
889
827
 
890
- return HTMLResponse(content=get_oauth_success_html(), status_code=200)
828
+ return {"status": "success", "message": "Authorization successful", "server_url": success.server_url}
891
829
 
892
830
  except Exception as e:
893
831
  logger.error(f"OAuth callback error: {e}")
@@ -932,9 +870,11 @@ async def generate_tool_from_prompt(
932
870
  )
933
871
  assert llm_client is not None
934
872
 
873
+ assistant_message_ack = "Understood, I will respond with generated python source code and sample arguments that can be used to test the functionality once I receive the user prompt. I'm ready."
874
+
935
875
  input_messages = [
936
- Message(role=MessageRole.system, content=[TextContent(text="Placeholder system message")]),
937
- Message(role=MessageRole.assistant, content=[TextContent(text="Placeholder assistant message")]),
876
+ Message(role=MessageRole.system, content=[TextContent(text=get_system_text("memgpt_generate_tool"))]),
877
+ Message(role=MessageRole.assistant, content=[TextContent(text=assistant_message_ack)]),
938
878
  Message(role=MessageRole.user, content=[TextContent(text=formatted_prompt)]),
939
879
  ]
940
880
 
@@ -965,11 +905,13 @@ async def generate_tool_from_prompt(
965
905
  response_data = await llm_client.request_async(request_data, llm_config)
966
906
  response = llm_client.convert_response_to_chat_completion(response_data, input_messages, llm_config)
967
907
  output = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
908
+ pip_requirements = [PipRequirement(name=k, version=v or None) for k, v in json.loads(output["pip_requirements_json"]).items()]
968
909
  return GenerateToolOutput(
969
910
  tool=Tool(
970
911
  name=request.tool_name,
971
912
  source_type="python",
972
913
  source_code=output["raw_source_code"],
914
+ pip_requirements=pip_requirements,
973
915
  ),
974
916
  sample_args=json.loads(output["sample_args_json"]),
975
917
  response=response.choices[0].message.content,