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.
@@ -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 if ":1234" in llm_config.model_endpoint else INNER_THOUGHTS_KWARG_DESCRIPTION
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 self.requires_auto_tool_choice(llm_config):
202
- tool_choice = "auto"
203
- elif tools:
204
- # only set if tools is non-Null
205
- tool_choice = "required"
206
-
207
- if force_tool_call is not None:
208
- tool_choice = ToolFunctionChoice(type="function", function=ToolFunctionChoiceFunctionCall(name=force_tool_call))
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(conn, cursor, statement, parameters, context, executemany):
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:
@@ -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 BedrockPermissionError, LettaAgentNotFoundError, LettaUserNotFoundError
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
- raw_stream = agent_loop.stream(
1324
- input_messages=request.messages,
1325
- max_steps=request.max_steps,
1326
- stream_tokens=request.stream_tokens and model_compatible_token_streaming,
1327
- run_id=run.id if run else None,
1328
- use_assistant_message=request.use_assistant_message,
1329
- request_start_timestamp_ns=request_start_timestamp_ns,
1330
- include_return_message_types=request.include_return_message_types,
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
- if tool_settings.mcp_read_from_config:
563
- try:
564
- return await server.get_tools_from_mcp_server(mcp_server_name=mcp_server_name)
565
- except ValueError as e:
566
- # ValueError means that the MCP server name doesn't exist
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=400, # Bad Request
548
+ status_code=404,
569
549
  detail={
570
- "code": "MCPServerNotFoundError",
550
+ "code": "MCPListToolsError",
571
551
  "message": str(e),
572
552
  "mcp_server_name": mcp_server_name,
573
553
  },
574
554
  )
575
- except MCPTimeoutError as e:
555
+ if isinstance(e, HTTPStatusError):
576
556
  raise HTTPException(
577
- status_code=408, # Timeout
557
+ status_code=401,
578
558
  detail={
579
- "code": "MCPTimeoutError",
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
- await server.mcp_manager.create_mcp_server(mapped_request, actor=actor)
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
 
@@ -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
- if job_update.status in {JobStatus.completed, JobStatus.failed} and job.callback_url:
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
- job_update_builder = partial(job_update_builder, metadata=metadata)
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
 
@@ -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.info(f"Error listing tools for MCP server {mcp_server_name}: {e}")
83
- return []
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
- await mcp_client.cleanup()
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."""