letta-nightly 0.9.1.dev20250731104458__py3-none-any.whl → 0.10.0.dev20250801060805__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.
- letta/__init__.py +2 -1
- letta/agent.py +1 -1
- letta/agents/base_agent.py +2 -2
- letta/agents/letta_agent.py +22 -8
- letta/agents/letta_agent_batch.py +2 -2
- letta/agents/voice_agent.py +2 -2
- letta/client/client.py +0 -11
- letta/data_sources/redis_client.py +1 -2
- letta/errors.py +11 -0
- letta/functions/function_sets/builtin.py +3 -7
- letta/functions/mcp_client/types.py +107 -1
- letta/helpers/reasoning_helper.py +48 -0
- letta/helpers/tool_execution_helper.py +2 -65
- letta/interfaces/openai_streaming_interface.py +38 -2
- letta/llm_api/anthropic_client.py +1 -5
- letta/llm_api/google_vertex_client.py +1 -1
- letta/llm_api/llm_client.py +1 -1
- letta/llm_api/openai_client.py +2 -0
- letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +3 -2
- letta/orm/agent.py +5 -0
- letta/orm/enums.py +0 -1
- letta/orm/file.py +0 -1
- letta/orm/files_agents.py +9 -9
- letta/orm/sandbox_config.py +1 -1
- letta/orm/sqlite_functions.py +15 -13
- letta/prompts/system/memgpt_generate_tool.txt +139 -0
- letta/schemas/agent.py +15 -1
- letta/schemas/enums.py +6 -0
- letta/schemas/file.py +3 -3
- letta/schemas/letta_ping.py +28 -0
- letta/schemas/letta_request.py +9 -0
- letta/schemas/letta_stop_reason.py +25 -0
- letta/schemas/llm_config.py +1 -0
- letta/schemas/mcp.py +16 -3
- letta/schemas/memory.py +5 -0
- letta/schemas/providers/lmstudio.py +7 -0
- letta/schemas/providers/ollama.py +11 -8
- letta/schemas/sandbox_config.py +17 -7
- letta/server/rest_api/app.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +93 -30
- letta/server/rest_api/routers/v1/blocks.py +52 -0
- letta/server/rest_api/routers/v1/sandbox_configs.py +2 -1
- letta/server/rest_api/routers/v1/tools.py +43 -101
- letta/server/rest_api/streaming_response.py +121 -9
- letta/server/server.py +6 -10
- letta/services/agent_manager.py +41 -4
- letta/services/block_manager.py +63 -1
- letta/services/file_processor/chunker/line_chunker.py +20 -19
- letta/services/file_processor/file_processor.py +0 -2
- letta/services/file_processor/file_types.py +1 -2
- letta/services/files_agents_manager.py +46 -6
- letta/services/helpers/agent_manager_helper.py +185 -13
- letta/services/job_manager.py +4 -4
- letta/services/mcp/oauth_utils.py +6 -150
- letta/services/mcp_manager.py +120 -2
- letta/services/sandbox_config_manager.py +3 -5
- letta/services/tool_executor/builtin_tool_executor.py +13 -18
- letta/services/tool_executor/files_tool_executor.py +31 -27
- letta/services/tool_executor/mcp_tool_executor.py +10 -1
- letta/services/tool_executor/{tool_executor.py → sandbox_tool_executor.py} +14 -2
- letta/services/tool_executor/tool_execution_manager.py +1 -1
- letta/services/tool_executor/tool_execution_sandbox.py +2 -1
- letta/services/tool_manager.py +59 -21
- letta/services/tool_sandbox/base.py +18 -2
- letta/services/tool_sandbox/e2b_sandbox.py +5 -35
- letta/services/tool_sandbox/local_sandbox.py +5 -22
- letta/services/tool_sandbox/modal_sandbox.py +205 -0
- letta/settings.py +27 -8
- letta/system.py +1 -4
- letta/templates/template_helper.py +5 -0
- letta/utils.py +14 -2
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/METADATA +7 -3
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/RECORD +76 -73
- letta/orm/__all__.py +0 -15
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/LICENSE +0 -0
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/WHEEL +0 -0
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/entry_points.txt +0 -0
letta/schemas/sandbox_config.py
CHANGED
@@ -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(
|
137
|
+
config: Union[LocalSandboxConfig, E2BSandboxConfig, ModalSandboxConfig] = Field(
|
138
|
+
None, description="The JSON configuration data for the sandbox."
|
139
|
+
)
|
letta/server/rest_api/app.py
CHANGED
@@ -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=
|
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 [
|
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 [
|
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
|
-
|
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
|
-
|
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 [
|
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",
|
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 [
|
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
|
-
|
1423
|
-
|
1424
|
-
|
1425
|
-
|
1426
|
-
|
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
|
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
|
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
|
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
|
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
|
-
|
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
|
795
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
801
|
-
|
802
|
-
#
|
803
|
-
|
804
|
-
|
805
|
-
|
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
|
-
|
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"
|
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
|
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="
|
937
|
-
Message(role=MessageRole.assistant, content=[TextContent(text=
|
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,
|