letta-nightly 0.11.7.dev20250911104039__py3-none-any.whl → 0.11.7.dev20250913103940__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/adapters/letta_llm_stream_adapter.py +1 -1
- letta/agents/letta_agent_v2.py +46 -10
- letta/helpers/tpuf_client.py +41 -9
- letta/interfaces/openai_streaming_interface.py +11 -74
- letta/llm_api/anthropic_client.py +2 -2
- letta/llm_api/azure_client.py +5 -2
- letta/llm_api/google_vertex_client.py +158 -16
- letta/llm_api/openai_client.py +14 -11
- letta/orm/job.py +5 -1
- letta/orm/organization.py +2 -0
- letta/otel/sqlalchemy_instrumentation.py +6 -1
- letta/schemas/letta_stop_reason.py +2 -0
- letta/server/rest_api/app.py +61 -1
- letta/server/rest_api/redis_stream_manager.py +15 -2
- letta/server/rest_api/routers/v1/agents.py +53 -15
- letta/server/rest_api/routers/v1/tools.py +23 -39
- letta/services/job_manager.py +15 -3
- letta/services/mcp_manager.py +64 -3
- letta/services/tool_executor/files_tool_executor.py +2 -2
- {letta_nightly-0.11.7.dev20250911104039.dist-info → letta_nightly-0.11.7.dev20250913103940.dist-info}/METADATA +3 -3
- {letta_nightly-0.11.7.dev20250911104039.dist-info → letta_nightly-0.11.7.dev20250913103940.dist-info}/RECORD +24 -24
- {letta_nightly-0.11.7.dev20250911104039.dist-info → letta_nightly-0.11.7.dev20250913103940.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20250911104039.dist-info → letta_nightly-0.11.7.dev20250913103940.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.7.dev20250911104039.dist-info → letta_nightly-0.11.7.dev20250913103940.dist-info}/licenses/LICENSE +0 -0
letta/llm_api/openai_client.py
CHANGED
@@ -99,7 +99,7 @@ def supports_structured_output(llm_config: LLMConfig) -> bool:
|
|
99
99
|
|
100
100
|
# FIXME pretty hacky - turn off for providers we know users will use,
|
101
101
|
# but also don't support structured output
|
102
|
-
if "nebius.com" in llm_config.model_endpoint:
|
102
|
+
if llm_config.model_endpoint and "nebius.com" in llm_config.model_endpoint:
|
103
103
|
return False
|
104
104
|
else:
|
105
105
|
return True
|
@@ -108,7 +108,7 @@ def supports_structured_output(llm_config: LLMConfig) -> bool:
|
|
108
108
|
# TODO move into LLMConfig as a field?
|
109
109
|
def requires_auto_tool_choice(llm_config: LLMConfig) -> bool:
|
110
110
|
"""Certain providers require the tool choice to be set to 'auto'."""
|
111
|
-
if "nebius.com" in llm_config.model_endpoint:
|
111
|
+
if llm_config.model_endpoint and "nebius.com" in llm_config.model_endpoint:
|
112
112
|
return True
|
113
113
|
if llm_config.handle and "vllm" in llm_config.handle:
|
114
114
|
return True
|
@@ -168,7 +168,9 @@ class OpenAIClient(LLMClientBase):
|
|
168
168
|
# Special case for LM Studio backend since it needs extra guidance to force out the thoughts first
|
169
169
|
# TODO(fix)
|
170
170
|
inner_thoughts_desc = (
|
171
|
-
INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST
|
171
|
+
INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST
|
172
|
+
if llm_config.model_endpoint and ":1234" in llm_config.model_endpoint
|
173
|
+
else INNER_THOUGHTS_KWARG_DESCRIPTION
|
172
174
|
)
|
173
175
|
tools = add_inner_thoughts_to_functions(
|
174
176
|
functions=tools,
|
@@ -198,14 +200,15 @@ class OpenAIClient(LLMClientBase):
|
|
198
200
|
# TODO(matt) move into LLMConfig
|
199
201
|
# TODO: This vllm checking is very brittle and is a patch at most
|
200
202
|
tool_choice = None
|
201
|
-
if
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
203
|
+
if tools: # only set tool_choice if tools exist
|
204
|
+
if self.requires_auto_tool_choice(llm_config):
|
205
|
+
tool_choice = "auto"
|
206
|
+
else:
|
207
|
+
# only set if tools is non-Null
|
208
|
+
tool_choice = "required"
|
209
|
+
|
210
|
+
if force_tool_call is not None:
|
211
|
+
tool_choice = ToolFunctionChoice(type="function", function=ToolFunctionChoiceFunctionCall(name=force_tool_call))
|
209
212
|
|
210
213
|
data = ChatCompletionRequest(
|
211
214
|
model=model,
|
letta/orm/job.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
from datetime import datetime
|
2
2
|
from typing import TYPE_CHECKING, List, Optional
|
3
3
|
|
4
|
-
from sqlalchemy import JSON, BigInteger, Index, String
|
4
|
+
from sqlalchemy import JSON, BigInteger, ForeignKey, Index, String
|
5
5
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
6
6
|
|
7
7
|
from letta.orm.mixins import UserMixin
|
@@ -12,6 +12,7 @@ from letta.schemas.job import Job as PydanticJob, LettaRequestConfig
|
|
12
12
|
if TYPE_CHECKING:
|
13
13
|
from letta.orm.job_messages import JobMessage
|
14
14
|
from letta.orm.message import Message
|
15
|
+
from letta.orm.organization import Organization
|
15
16
|
from letta.orm.step import Step
|
16
17
|
from letta.orm.user import User
|
17
18
|
|
@@ -36,6 +37,7 @@ class Job(SqlalchemyBase, UserMixin):
|
|
36
37
|
request_config: Mapped[Optional[LettaRequestConfig]] = mapped_column(
|
37
38
|
JSON, nullable=True, doc="The request configuration for the job, stored as JSON."
|
38
39
|
)
|
40
|
+
organization_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("organizations.id"))
|
39
41
|
|
40
42
|
# callback related columns
|
41
43
|
callback_url: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="When set, POST to this URL after job completion.")
|
@@ -53,6 +55,8 @@ class Job(SqlalchemyBase, UserMixin):
|
|
53
55
|
user: Mapped["User"] = relationship("User", back_populates="jobs")
|
54
56
|
job_messages: Mapped[List["JobMessage"]] = relationship("JobMessage", back_populates="job", cascade="all, delete-orphan")
|
55
57
|
steps: Mapped[List["Step"]] = relationship("Step", back_populates="job", cascade="save-update")
|
58
|
+
# organization relationship (nullable for backward compatibility)
|
59
|
+
organization: Mapped[Optional["Organization"]] = relationship("Organization", back_populates="jobs")
|
56
60
|
|
57
61
|
@property
|
58
62
|
def messages(self) -> List["Message"]:
|
letta/orm/organization.py
CHANGED
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
|
|
12
12
|
from letta.orm.block import Block
|
13
13
|
from letta.orm.group import Group
|
14
14
|
from letta.orm.identity import Identity
|
15
|
+
from letta.orm.job import Job
|
15
16
|
from letta.orm.llm_batch_items import LLMBatchItem
|
16
17
|
from letta.orm.llm_batch_job import LLMBatchJob
|
17
18
|
from letta.orm.message import Message
|
@@ -66,3 +67,4 @@ class Organization(SqlalchemyBase):
|
|
66
67
|
llm_batch_items: Mapped[List["LLMBatchItem"]] = relationship(
|
67
68
|
"LLMBatchItem", back_populates="organization", cascade="all, delete-orphan"
|
68
69
|
)
|
70
|
+
jobs: Mapped[List["Job"]] = relationship("Job", back_populates="organization", cascade="all, delete-orphan")
|
@@ -146,11 +146,16 @@ def _instrument_engine_events(engine: Engine) -> None:
|
|
146
146
|
span.end()
|
147
147
|
context._sync_instrumentation_span = None
|
148
148
|
|
149
|
-
def handle_cursor_error(
|
149
|
+
def handle_cursor_error(exception_context):
|
150
150
|
"""Handle cursor execution errors."""
|
151
151
|
if not _config["enabled"]:
|
152
152
|
return
|
153
153
|
|
154
|
+
# Extract context from exception_context
|
155
|
+
context = getattr(exception_context, "execution_context", None)
|
156
|
+
if not context:
|
157
|
+
return
|
158
|
+
|
154
159
|
span = getattr(context, "_sync_instrumentation_span", None)
|
155
160
|
if span:
|
156
161
|
span.set_status(Status(StatusCode.ERROR, "Database operation failed"))
|
@@ -9,6 +9,7 @@ from letta.schemas.enums import JobStatus
|
|
9
9
|
class StopReasonType(str, Enum):
|
10
10
|
end_turn = "end_turn"
|
11
11
|
error = "error"
|
12
|
+
llm_api_error = "llm_api_error"
|
12
13
|
invalid_llm_response = "invalid_llm_response"
|
13
14
|
invalid_tool_call = "invalid_tool_call"
|
14
15
|
max_steps = "max_steps"
|
@@ -31,6 +32,7 @@ class StopReasonType(str, Enum):
|
|
31
32
|
StopReasonType.invalid_tool_call,
|
32
33
|
StopReasonType.no_tool_call,
|
33
34
|
StopReasonType.invalid_llm_response,
|
35
|
+
StopReasonType.llm_api_error,
|
34
36
|
):
|
35
37
|
return JobStatus.failed
|
36
38
|
elif self == StopReasonType.cancelled:
|
letta/server/rest_api/app.py
CHANGED
@@ -17,7 +17,15 @@ from starlette.middleware.cors import CORSMiddleware
|
|
17
17
|
from letta.__init__ import __version__ as letta_version
|
18
18
|
from letta.agents.exceptions import IncompatibleAgentType
|
19
19
|
from letta.constants import ADMIN_PREFIX, API_PREFIX, OPENAI_API_PREFIX
|
20
|
-
from letta.errors import
|
20
|
+
from letta.errors import (
|
21
|
+
BedrockPermissionError,
|
22
|
+
LettaAgentNotFoundError,
|
23
|
+
LettaUserNotFoundError,
|
24
|
+
LLMAuthenticationError,
|
25
|
+
LLMError,
|
26
|
+
LLMRateLimitError,
|
27
|
+
LLMTimeoutError,
|
28
|
+
)
|
21
29
|
from letta.helpers.pinecone_utils import get_pinecone_indices, should_use_pinecone, upsert_pinecone_indices
|
22
30
|
from letta.jobs.scheduler import start_scheduler_with_leader_election
|
23
31
|
from letta.log import get_logger
|
@@ -276,6 +284,58 @@ def create_application() -> "FastAPI":
|
|
276
284
|
},
|
277
285
|
)
|
278
286
|
|
287
|
+
@app.exception_handler(LLMTimeoutError)
|
288
|
+
async def llm_timeout_error_handler(request: Request, exc: LLMTimeoutError):
|
289
|
+
return JSONResponse(
|
290
|
+
status_code=504,
|
291
|
+
content={
|
292
|
+
"error": {
|
293
|
+
"type": "llm_timeout",
|
294
|
+
"message": "The LLM request timed out. Please try again.",
|
295
|
+
"detail": str(exc),
|
296
|
+
}
|
297
|
+
},
|
298
|
+
)
|
299
|
+
|
300
|
+
@app.exception_handler(LLMRateLimitError)
|
301
|
+
async def llm_rate_limit_error_handler(request: Request, exc: LLMRateLimitError):
|
302
|
+
return JSONResponse(
|
303
|
+
status_code=429,
|
304
|
+
content={
|
305
|
+
"error": {
|
306
|
+
"type": "llm_rate_limit",
|
307
|
+
"message": "Rate limit exceeded for LLM model provider. Please wait before making another request.",
|
308
|
+
"detail": str(exc),
|
309
|
+
}
|
310
|
+
},
|
311
|
+
)
|
312
|
+
|
313
|
+
@app.exception_handler(LLMAuthenticationError)
|
314
|
+
async def llm_auth_error_handler(request: Request, exc: LLMAuthenticationError):
|
315
|
+
return JSONResponse(
|
316
|
+
status_code=401,
|
317
|
+
content={
|
318
|
+
"error": {
|
319
|
+
"type": "llm_authentication",
|
320
|
+
"message": "Authentication failed with the LLM model provider.",
|
321
|
+
"detail": str(exc),
|
322
|
+
}
|
323
|
+
},
|
324
|
+
)
|
325
|
+
|
326
|
+
@app.exception_handler(LLMError)
|
327
|
+
async def llm_error_handler(request: Request, exc: LLMError):
|
328
|
+
return JSONResponse(
|
329
|
+
status_code=502,
|
330
|
+
content={
|
331
|
+
"error": {
|
332
|
+
"type": "llm_error",
|
333
|
+
"message": "An error occurred with the LLM request.",
|
334
|
+
"detail": str(exc),
|
335
|
+
}
|
336
|
+
},
|
337
|
+
)
|
338
|
+
|
279
339
|
settings.cors_origins.append("https://app.letta.com")
|
280
340
|
|
281
341
|
if (os.getenv("LETTA_SERVER_SECURE") == "true") or "--secure" in sys.argv:
|
@@ -8,6 +8,9 @@ from typing import AsyncIterator, Dict, List, Optional
|
|
8
8
|
|
9
9
|
from letta.data_sources.redis_client import AsyncRedisClient
|
10
10
|
from letta.log import get_logger
|
11
|
+
from letta.schemas.enums import JobStatus
|
12
|
+
from letta.schemas.user import User
|
13
|
+
from letta.services.job_manager import JobManager
|
11
14
|
from letta.utils import safe_create_task
|
12
15
|
|
13
16
|
logger = get_logger(__name__)
|
@@ -133,9 +136,9 @@ class RedisSSEStreamWriter:
|
|
133
136
|
|
134
137
|
async with client.pipeline(transaction=False) as pipe:
|
135
138
|
for chunk in chunks:
|
136
|
-
pipe.xadd(stream_key, chunk, maxlen=self.max_stream_length, approximate=True)
|
139
|
+
await pipe.xadd(stream_key, chunk, maxlen=self.max_stream_length, approximate=True)
|
137
140
|
|
138
|
-
pipe.expire(stream_key, self.stream_ttl)
|
141
|
+
await pipe.expire(stream_key, self.stream_ttl)
|
139
142
|
|
140
143
|
await pipe.execute()
|
141
144
|
|
@@ -191,6 +194,8 @@ async def create_background_stream_processor(
|
|
191
194
|
redis_client: AsyncRedisClient,
|
192
195
|
run_id: str,
|
193
196
|
writer: Optional[RedisSSEStreamWriter] = None,
|
197
|
+
job_manager: Optional[JobManager] = None,
|
198
|
+
actor: Optional[User] = None,
|
194
199
|
) -> None:
|
195
200
|
"""
|
196
201
|
Process a stream in the background and store chunks to Redis.
|
@@ -203,6 +208,8 @@ async def create_background_stream_processor(
|
|
203
208
|
redis_client: Redis client instance
|
204
209
|
run_id: The run ID to store chunks under
|
205
210
|
writer: Optional pre-configured writer (creates new if not provided)
|
211
|
+
job_manager: Optional job manager for updating job status
|
212
|
+
actor: Optional actor for job status updates
|
206
213
|
"""
|
207
214
|
if writer is None:
|
208
215
|
writer = RedisSSEStreamWriter(redis_client)
|
@@ -227,6 +234,12 @@ async def create_background_stream_processor(
|
|
227
234
|
logger.error(f"Error processing stream for run {run_id}: {e}")
|
228
235
|
# Write error chunk
|
229
236
|
# error_chunk = {"error": {"message": str(e)}}
|
237
|
+
# Mark run_id terminal state
|
238
|
+
if job_manager and actor:
|
239
|
+
await job_manager.safe_update_job_status_async(
|
240
|
+
job_id=run_id, new_status=JobStatus.failed, actor=actor, metadata={"error": str(e)}
|
241
|
+
)
|
242
|
+
|
230
243
|
error_chunk = {"error": str(e), "code": "INTERNAL_SERVER_ERROR"}
|
231
244
|
await writer.write_chunk(run_id=run_id, data=f"event: error\ndata: {json.dumps(error_chunk)}\n\n", is_complete=True)
|
232
245
|
finally:
|
@@ -536,9 +536,7 @@ async def attach_source(
|
|
536
536
|
|
537
537
|
if agent_state.enable_sleeptime:
|
538
538
|
source = await server.source_manager.get_source_by_id(source_id=source_id)
|
539
|
-
safe_create_task(
|
540
|
-
server.sleeptime_document_ingest_async(agent_state, source, actor), logger=logger, label="sleeptime_document_ingest_async"
|
541
|
-
)
|
539
|
+
safe_create_task(server.sleeptime_document_ingest_async(agent_state, source, actor), label="sleeptime_document_ingest_async")
|
542
540
|
|
543
541
|
return agent_state
|
544
542
|
|
@@ -565,9 +563,7 @@ async def attach_folder_to_agent(
|
|
565
563
|
|
566
564
|
if agent_state.enable_sleeptime:
|
567
565
|
source = await server.source_manager.get_source_by_id(source_id=folder_id)
|
568
|
-
safe_create_task(
|
569
|
-
server.sleeptime_document_ingest_async(agent_state, source, actor), logger=logger, label="sleeptime_document_ingest_async"
|
570
|
-
)
|
566
|
+
safe_create_task(server.sleeptime_document_ingest_async(agent_state, source, actor), label="sleeptime_document_ingest_async")
|
571
567
|
|
572
568
|
return agent_state
|
573
569
|
|
@@ -1320,15 +1316,55 @@ async def send_message_streaming(
|
|
1320
1316
|
try:
|
1321
1317
|
if agent_eligible and model_compatible:
|
1322
1318
|
agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
|
1323
|
-
|
1324
|
-
|
1325
|
-
|
1326
|
-
|
1327
|
-
|
1328
|
-
|
1329
|
-
|
1330
|
-
|
1331
|
-
|
1319
|
+
|
1320
|
+
async def error_aware_stream():
|
1321
|
+
"""Stream that handles early LLM errors gracefully in streaming format."""
|
1322
|
+
from letta.errors import LLMAuthenticationError, LLMError, LLMRateLimitError, LLMTimeoutError
|
1323
|
+
|
1324
|
+
try:
|
1325
|
+
stream = agent_loop.stream(
|
1326
|
+
input_messages=request.messages,
|
1327
|
+
max_steps=request.max_steps,
|
1328
|
+
stream_tokens=request.stream_tokens and model_compatible_token_streaming,
|
1329
|
+
run_id=run.id if run else None,
|
1330
|
+
use_assistant_message=request.use_assistant_message,
|
1331
|
+
request_start_timestamp_ns=request_start_timestamp_ns,
|
1332
|
+
include_return_message_types=request.include_return_message_types,
|
1333
|
+
)
|
1334
|
+
async for chunk in stream:
|
1335
|
+
yield chunk
|
1336
|
+
|
1337
|
+
except LLMTimeoutError as e:
|
1338
|
+
error_data = {
|
1339
|
+
"error": {"type": "llm_timeout", "message": "The LLM request timed out. Please try again.", "detail": str(e)}
|
1340
|
+
}
|
1341
|
+
yield (f"data: {json.dumps(error_data)}\n\n", 504)
|
1342
|
+
except LLMRateLimitError as e:
|
1343
|
+
error_data = {
|
1344
|
+
"error": {
|
1345
|
+
"type": "llm_rate_limit",
|
1346
|
+
"message": "Rate limit exceeded for LLM model provider. Please wait before making another request.",
|
1347
|
+
"detail": str(e),
|
1348
|
+
}
|
1349
|
+
}
|
1350
|
+
yield (f"data: {json.dumps(error_data)}\n\n", 429)
|
1351
|
+
except LLMAuthenticationError as e:
|
1352
|
+
error_data = {
|
1353
|
+
"error": {
|
1354
|
+
"type": "llm_authentication",
|
1355
|
+
"message": "Authentication failed with the LLM model provider.",
|
1356
|
+
"detail": str(e),
|
1357
|
+
}
|
1358
|
+
}
|
1359
|
+
yield (f"data: {json.dumps(error_data)}\n\n", 401)
|
1360
|
+
except LLMError as e:
|
1361
|
+
error_data = {"error": {"type": "llm_error", "message": "An error occurred with the LLM request.", "detail": str(e)}}
|
1362
|
+
yield (f"data: {json.dumps(error_data)}\n\n", 502)
|
1363
|
+
except Exception as e:
|
1364
|
+
error_data = {"error": {"type": "internal_error", "message": "An internal server error occurred.", "detail": str(e)}}
|
1365
|
+
yield (f"data: {json.dumps(error_data)}\n\n", 500)
|
1366
|
+
|
1367
|
+
raw_stream = error_aware_stream()
|
1332
1368
|
|
1333
1369
|
from letta.server.rest_api.streaming_response import StreamingResponseWithStatusCode, add_keepalive_to_stream
|
1334
1370
|
|
@@ -1348,6 +1384,8 @@ async def send_message_streaming(
|
|
1348
1384
|
stream_generator=raw_stream,
|
1349
1385
|
redis_client=redis_client,
|
1350
1386
|
run_id=run.id,
|
1387
|
+
job_manager=server.job_manager,
|
1388
|
+
actor=actor,
|
1351
1389
|
),
|
1352
1390
|
label=f"background_stream_processor_{run.id}",
|
1353
1391
|
)
|
@@ -12,7 +12,7 @@ from composio.exceptions import (
|
|
12
12
|
EnumStringNotFound,
|
13
13
|
)
|
14
14
|
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request
|
15
|
-
from httpx import HTTPStatusError
|
15
|
+
from httpx import ConnectError, HTTPStatusError
|
16
16
|
from pydantic import BaseModel, Field
|
17
17
|
from starlette.responses import StreamingResponse
|
18
18
|
|
@@ -151,7 +151,6 @@ async def count_tools(
|
|
151
151
|
exclude_letta_tools=exclude_letta_tools,
|
152
152
|
)
|
153
153
|
except Exception as e:
|
154
|
-
print(f"Error occurred: {e}")
|
155
154
|
raise HTTPException(status_code=500, detail=str(e))
|
156
155
|
|
157
156
|
|
@@ -265,8 +264,6 @@ async def list_tools(
|
|
265
264
|
return_only_letta_tools=return_only_letta_tools,
|
266
265
|
)
|
267
266
|
except Exception as e:
|
268
|
-
# Log or print the full exception here for debugging
|
269
|
-
print(f"Error occurred: {e}")
|
270
267
|
raise HTTPException(status_code=500, detail=str(e))
|
271
268
|
|
272
269
|
|
@@ -284,21 +281,13 @@ async def create_tool(
|
|
284
281
|
tool = Tool(**request.model_dump(exclude_unset=True))
|
285
282
|
return await server.tool_manager.create_tool_async(pydantic_tool=tool, actor=actor)
|
286
283
|
except UniqueConstraintViolationError as e:
|
287
|
-
# Log or print the full exception here for debugging
|
288
|
-
print(f"Error occurred: {e}")
|
289
284
|
clean_error_message = "Tool with this name already exists."
|
290
285
|
raise HTTPException(status_code=409, detail=clean_error_message)
|
291
286
|
except LettaToolCreateError as e:
|
292
287
|
# HTTP 400 == Bad Request
|
293
|
-
print(f"Error occurred during tool creation: {e}")
|
294
|
-
# print the full stack trace
|
295
|
-
import traceback
|
296
|
-
|
297
|
-
print(traceback.format_exc())
|
298
288
|
raise HTTPException(status_code=400, detail=str(e))
|
299
289
|
except Exception as e:
|
300
290
|
# Catch other unexpected errors and raise an internal server error
|
301
|
-
print(f"Unexpected error occurred: {e}")
|
302
291
|
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
|
303
292
|
|
304
293
|
|
@@ -319,15 +308,12 @@ async def upsert_tool(
|
|
319
308
|
return tool
|
320
309
|
except UniqueConstraintViolationError as e:
|
321
310
|
# Log the error and raise a conflict exception
|
322
|
-
print(f"Unique constraint violation occurred: {e}")
|
323
311
|
raise HTTPException(status_code=409, detail=str(e))
|
324
312
|
except LettaToolCreateError as e:
|
325
313
|
# HTTP 400 == Bad Request
|
326
|
-
print(f"Error occurred during tool upsert: {e}")
|
327
314
|
raise HTTPException(status_code=400, detail=str(e))
|
328
315
|
except Exception as e:
|
329
316
|
# Catch other unexpected errors and raise an internal server error
|
330
|
-
print(f"Unexpected error occurred: {e}")
|
331
317
|
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
|
332
318
|
|
333
319
|
|
@@ -344,7 +330,6 @@ async def modify_tool(
|
|
344
330
|
try:
|
345
331
|
actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
|
346
332
|
tool = await server.tool_manager.update_tool_by_id_async(tool_id=tool_id, tool_update=request, actor=actor)
|
347
|
-
print("FINAL TOOL", tool)
|
348
333
|
return tool
|
349
334
|
except LettaToolNameConflictError as e:
|
350
335
|
# HTTP 409 == Conflict
|
@@ -394,16 +379,10 @@ async def run_tool_from_source(
|
|
394
379
|
)
|
395
380
|
except LettaToolCreateError as e:
|
396
381
|
# HTTP 400 == Bad Request
|
397
|
-
print(f"Error occurred during tool creation: {e}")
|
398
|
-
# print the full stack trace
|
399
|
-
import traceback
|
400
|
-
|
401
|
-
print(traceback.format_exc())
|
402
382
|
raise HTTPException(status_code=400, detail=str(e))
|
403
383
|
|
404
384
|
except Exception as e:
|
405
385
|
# Catch other unexpected errors and raise an internal server error
|
406
|
-
print(f"Unexpected error occurred: {e}")
|
407
386
|
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
|
408
387
|
|
409
388
|
|
@@ -559,32 +538,38 @@ async def list_mcp_tools_by_server(
|
|
559
538
|
"""
|
560
539
|
Get a list of all tools for a specific MCP server
|
561
540
|
"""
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
|
541
|
+
try:
|
542
|
+
actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
|
543
|
+
mcp_tools = await server.mcp_manager.list_mcp_server_tools(mcp_server_name=mcp_server_name, actor=actor)
|
544
|
+
return mcp_tools
|
545
|
+
except Exception as e:
|
546
|
+
if isinstance(e, ConnectError) or isinstance(e, ConnectionError):
|
567
547
|
raise HTTPException(
|
568
|
-
status_code=
|
548
|
+
status_code=404,
|
569
549
|
detail={
|
570
|
-
"code": "
|
550
|
+
"code": "MCPListToolsError",
|
571
551
|
"message": str(e),
|
572
552
|
"mcp_server_name": mcp_server_name,
|
573
553
|
},
|
574
554
|
)
|
575
|
-
|
555
|
+
if isinstance(e, HTTPStatusError):
|
576
556
|
raise HTTPException(
|
577
|
-
status_code=
|
557
|
+
status_code=401,
|
578
558
|
detail={
|
579
|
-
"code": "
|
559
|
+
"code": "MCPListToolsError",
|
560
|
+
"message": str(e),
|
561
|
+
"mcp_server_name": mcp_server_name,
|
562
|
+
},
|
563
|
+
)
|
564
|
+
else:
|
565
|
+
raise HTTPException(
|
566
|
+
status_code=500,
|
567
|
+
detail={
|
568
|
+
"code": "MCPListToolsError",
|
580
569
|
"message": str(e),
|
581
570
|
"mcp_server_name": mcp_server_name,
|
582
571
|
},
|
583
572
|
)
|
584
|
-
else:
|
585
|
-
actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
|
586
|
-
mcp_tools = await server.mcp_manager.list_mcp_server_tools(mcp_server_name=mcp_server_name, actor=actor)
|
587
|
-
return mcp_tools
|
588
573
|
|
589
574
|
|
590
575
|
@router.post("/mcp/servers/{mcp_server_name}/resync", operation_id="resync_mcp_server_tools")
|
@@ -753,7 +738,8 @@ async def add_mcp_server_to_config(
|
|
753
738
|
custom_headers=request.custom_headers,
|
754
739
|
)
|
755
740
|
|
756
|
-
|
741
|
+
# Create MCP server and optimistically sync tools
|
742
|
+
await server.mcp_manager.create_mcp_server_with_tools(mapped_request, actor=actor)
|
757
743
|
|
758
744
|
# TODO: don't do this in the future (just return MCPServer)
|
759
745
|
all_servers = await server.mcp_manager.list_mcp_servers(actor=actor)
|
@@ -769,7 +755,6 @@ async def add_mcp_server_to_config(
|
|
769
755
|
},
|
770
756
|
)
|
771
757
|
except Exception as e:
|
772
|
-
print(f"Unexpected error occurred while adding MCP server: {e}")
|
773
758
|
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
|
774
759
|
|
775
760
|
|
@@ -801,7 +786,6 @@ async def update_mcp_server(
|
|
801
786
|
# Re-raise HTTP exceptions (like 404)
|
802
787
|
raise
|
803
788
|
except Exception as e:
|
804
|
-
print(f"Unexpected error occurred while updating MCP server: {e}")
|
805
789
|
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
|
806
790
|
|
807
791
|
|
letta/services/job_manager.py
CHANGED
@@ -43,6 +43,7 @@ class JobManager:
|
|
43
43
|
pydantic_job.user_id = actor.id
|
44
44
|
job_data = pydantic_job.model_dump(to_orm=True)
|
45
45
|
job = JobModel(**job_data)
|
46
|
+
job.organization_id = actor.organization_id
|
46
47
|
job.create(session, actor=actor) # Save job in the database
|
47
48
|
return job.to_pydantic()
|
48
49
|
|
@@ -57,6 +58,7 @@ class JobManager:
|
|
57
58
|
pydantic_job.user_id = actor.id
|
58
59
|
job_data = pydantic_job.model_dump(to_orm=True)
|
59
60
|
job = JobModel(**job_data)
|
61
|
+
job.organization_id = actor.organization_id
|
60
62
|
job = await job.create_async(session, actor=actor, no_commit=True, no_refresh=True) # Save job in the database
|
61
63
|
result = job.to_pydantic()
|
62
64
|
await session.commit()
|
@@ -150,8 +152,9 @@ class JobManager:
|
|
150
152
|
logger.error(f"Invalid job status transition from {current_status} to {job_update.status} for job {job_id}")
|
151
153
|
raise ValueError(f"Invalid job status transition from {current_status} to {job_update.status}")
|
152
154
|
|
153
|
-
# Check if we'll need to dispatch callback
|
154
|
-
|
155
|
+
# Check if we'll need to dispatch callback (only if not already completed)
|
156
|
+
not_completed_before = not bool(job.completed_at)
|
157
|
+
if job_update.status in {JobStatus.completed, JobStatus.failed} and not_completed_before and job.callback_url:
|
155
158
|
needs_callback = True
|
156
159
|
callback_url = job.callback_url
|
157
160
|
|
@@ -215,8 +218,17 @@ class JobManager:
|
|
215
218
|
"""
|
216
219
|
try:
|
217
220
|
job_update_builder = partial(JobUpdate, status=new_status)
|
221
|
+
|
222
|
+
# If metadata is provided, merge it with existing metadata
|
218
223
|
if metadata:
|
219
|
-
|
224
|
+
# Get the current job to access existing metadata
|
225
|
+
current_job = await self.get_job_by_id_async(job_id=job_id, actor=actor)
|
226
|
+
merged_metadata = {}
|
227
|
+
if current_job.metadata:
|
228
|
+
merged_metadata.update(current_job.metadata)
|
229
|
+
merged_metadata.update(metadata)
|
230
|
+
job_update_builder = partial(job_update_builder, metadata=merged_metadata)
|
231
|
+
|
220
232
|
if new_status.is_terminal:
|
221
233
|
job_update_builder = partial(job_update_builder, completed_at=get_utc_time())
|
222
234
|
|
letta/services/mcp_manager.py
CHANGED
@@ -79,11 +79,16 @@ class MCPManager:
|
|
79
79
|
except Exception as e:
|
80
80
|
# MCP tool listing errors are often due to connection/configuration issues, not system errors
|
81
81
|
# Log at info level to avoid triggering Sentry alerts for expected failures
|
82
|
-
logger.
|
83
|
-
|
82
|
+
logger.warning(f"Error listing tools for MCP server {mcp_server_name}: {e}")
|
83
|
+
raise e
|
84
84
|
finally:
|
85
85
|
if mcp_client:
|
86
|
-
|
86
|
+
try:
|
87
|
+
await mcp_client.cleanup()
|
88
|
+
except* Exception as eg:
|
89
|
+
for e in eg.exceptions:
|
90
|
+
logger.warning(f"Error listing tools for MCP server {mcp_server_name}: {e}")
|
91
|
+
raise e
|
87
92
|
|
88
93
|
@enforce_types
|
89
94
|
async def execute_mcp_server_tool(
|
@@ -349,6 +354,62 @@ class MCPManager:
|
|
349
354
|
logger.error(f"Failed to create MCP server: {e}")
|
350
355
|
raise
|
351
356
|
|
357
|
+
@enforce_types
|
358
|
+
async def create_mcp_server_with_tools(self, pydantic_mcp_server: MCPServer, actor: PydanticUser) -> MCPServer:
|
359
|
+
"""
|
360
|
+
Create a new MCP server and optimistically sync its tools.
|
361
|
+
|
362
|
+
This method:
|
363
|
+
1. Creates the MCP server record
|
364
|
+
2. Attempts to connect and fetch tools
|
365
|
+
3. Persists valid tools in parallel (best-effort)
|
366
|
+
"""
|
367
|
+
import asyncio
|
368
|
+
|
369
|
+
# First, create the MCP server
|
370
|
+
created_server = await self.create_mcp_server(pydantic_mcp_server, actor)
|
371
|
+
|
372
|
+
# Optimistically try to sync tools
|
373
|
+
try:
|
374
|
+
logger.info(f"Attempting to auto-sync tools from MCP server: {created_server.server_name}")
|
375
|
+
|
376
|
+
# List all tools from the MCP server
|
377
|
+
mcp_tools = await self.list_mcp_server_tools(mcp_server_name=created_server.server_name, actor=actor)
|
378
|
+
|
379
|
+
# Filter out invalid tools
|
380
|
+
valid_tools = [tool for tool in mcp_tools if not (tool.health and tool.health.status == "INVALID")]
|
381
|
+
|
382
|
+
# Register in parallel
|
383
|
+
if valid_tools:
|
384
|
+
tool_tasks = []
|
385
|
+
for mcp_tool in valid_tools:
|
386
|
+
tool_create = ToolCreate.from_mcp(mcp_server_name=created_server.server_name, mcp_tool=mcp_tool)
|
387
|
+
task = self.tool_manager.create_mcp_tool_async(
|
388
|
+
tool_create=tool_create, mcp_server_name=created_server.server_name, mcp_server_id=created_server.id, actor=actor
|
389
|
+
)
|
390
|
+
tool_tasks.append(task)
|
391
|
+
|
392
|
+
results = await asyncio.gather(*tool_tasks, return_exceptions=True)
|
393
|
+
|
394
|
+
successful = sum(1 for r in results if not isinstance(r, Exception))
|
395
|
+
failed = len(results) - successful
|
396
|
+
logger.info(
|
397
|
+
f"Auto-sync completed for MCP server {created_server.server_name}: "
|
398
|
+
f"{successful} tools persisted, {failed} failed, "
|
399
|
+
f"{len(mcp_tools) - len(valid_tools)} invalid tools skipped"
|
400
|
+
)
|
401
|
+
else:
|
402
|
+
logger.info(f"No valid tools found to sync from MCP server {created_server.server_name}")
|
403
|
+
|
404
|
+
except Exception as e:
|
405
|
+
# Log the error but don't fail the server creation
|
406
|
+
logger.warning(
|
407
|
+
f"Failed to auto-sync tools from MCP server {created_server.server_name}: {e}. "
|
408
|
+
f"Server was created successfully but tools were not persisted."
|
409
|
+
)
|
410
|
+
|
411
|
+
return created_server
|
412
|
+
|
352
413
|
@enforce_types
|
353
414
|
async def update_mcp_server_by_id(self, mcp_server_id: str, mcp_server_update: UpdateMCPServer, actor: PydanticUser) -> MCPServer:
|
354
415
|
"""Update a tool by its ID with the given ToolUpdate object."""
|