letta-nightly 0.11.7.dev20250910104051__py3-none-any.whl → 0.11.7.dev20250912104045__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. letta/adapters/letta_llm_request_adapter.py +4 -2
  2. letta/adapters/letta_llm_stream_adapter.py +4 -2
  3. letta/agents/agent_loop.py +23 -0
  4. letta/agents/letta_agent_v2.py +34 -12
  5. letta/functions/helpers.py +3 -2
  6. letta/groups/sleeptime_multi_agent_v2.py +4 -2
  7. letta/groups/sleeptime_multi_agent_v3.py +4 -2
  8. letta/helpers/tpuf_client.py +41 -9
  9. letta/interfaces/anthropic_streaming_interface.py +10 -6
  10. letta/interfaces/openai_streaming_interface.py +9 -74
  11. letta/llm_api/google_vertex_client.py +6 -1
  12. letta/llm_api/openai_client.py +9 -8
  13. letta/orm/agent.py +4 -1
  14. letta/orm/block.py +1 -0
  15. letta/orm/blocks_agents.py +1 -0
  16. letta/orm/job.py +5 -1
  17. letta/orm/organization.py +2 -0
  18. letta/orm/sources_agents.py +2 -1
  19. letta/orm/tools_agents.py +5 -2
  20. letta/schemas/message.py +19 -2
  21. letta/server/rest_api/interface.py +34 -2
  22. letta/server/rest_api/json_parser.py +2 -0
  23. letta/server/rest_api/redis_stream_manager.py +17 -3
  24. letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +4 -2
  25. letta/server/rest_api/routers/v1/agents.py +49 -180
  26. letta/server/rest_api/routers/v1/folders.py +2 -2
  27. letta/server/rest_api/routers/v1/sources.py +2 -2
  28. letta/server/rest_api/routers/v1/tools.py +23 -39
  29. letta/server/rest_api/streaming_response.py +2 -1
  30. letta/server/server.py +7 -5
  31. letta/services/agent_serialization_manager.py +4 -3
  32. letta/services/job_manager.py +5 -2
  33. letta/services/mcp_manager.py +66 -5
  34. letta/services/summarizer/summarizer.py +2 -1
  35. letta/services/tool_executor/files_tool_executor.py +2 -2
  36. letta/services/tool_executor/multi_agent_tool_executor.py +17 -14
  37. letta/services/tool_sandbox/local_sandbox.py +2 -2
  38. letta/services/tool_sandbox/modal_version_manager.py +2 -1
  39. letta/streaming_utils.py +29 -4
  40. letta/utils.py +72 -3
  41. {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/METADATA +3 -3
  42. {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/RECORD +45 -44
  43. {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/WHEEL +0 -0
  44. {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/entry_points.txt +0 -0
  45. {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/licenses/LICENSE +0 -0
@@ -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
 
@@ -19,6 +19,7 @@ from letta.schemas.user import User
19
19
  from letta.server.rest_api.utils import capture_sentry_exception
20
20
  from letta.services.job_manager import JobManager
21
21
  from letta.settings import settings
22
+ from letta.utils import safe_create_task
22
23
 
23
24
  logger = get_logger(__name__)
24
25
 
@@ -64,7 +65,7 @@ async def add_keepalive_to_stream(
64
65
  await queue.put(("end", None))
65
66
 
66
67
  # Start the stream reader task
67
- reader_task = asyncio.create_task(stream_reader())
68
+ reader_task = safe_create_task(stream_reader(), label="stream_reader")
68
69
 
69
70
  try:
70
71
  while True:
letta/server/server.py CHANGED
@@ -109,7 +109,7 @@ from letta.services.tool_manager import ToolManager
109
109
  from letta.services.user_manager import UserManager
110
110
  from letta.settings import DatabaseChoice, model_settings, settings, tool_settings
111
111
  from letta.streaming_interface import AgentChunkStreamingInterface
112
- from letta.utils import get_friendly_error_msg, get_persona_text, make_key
112
+ from letta.utils import get_friendly_error_msg, get_persona_text, make_key, safe_create_task
113
113
 
114
114
  config = LettaConfig.load()
115
115
  logger = get_logger(__name__)
@@ -2248,7 +2248,7 @@ class SyncServer(Server):
2248
2248
 
2249
2249
  # Offload the synchronous message_func to a separate thread
2250
2250
  streaming_interface.stream_start()
2251
- task = asyncio.create_task(
2251
+ task = safe_create_task(
2252
2252
  asyncio.to_thread(
2253
2253
  self.send_messages,
2254
2254
  actor=actor,
@@ -2256,7 +2256,8 @@ class SyncServer(Server):
2256
2256
  input_messages=input_messages,
2257
2257
  interface=streaming_interface,
2258
2258
  metadata=metadata,
2259
- )
2259
+ ),
2260
+ label="send_messages_thread",
2260
2261
  )
2261
2262
 
2262
2263
  if stream_steps:
@@ -2363,13 +2364,14 @@ class SyncServer(Server):
2363
2364
  streaming_interface.metadata = metadata
2364
2365
 
2365
2366
  streaming_interface.stream_start()
2366
- task = asyncio.create_task(
2367
+ task = safe_create_task(
2367
2368
  asyncio.to_thread(
2368
2369
  letta_multi_agent.step,
2369
2370
  input_messages=input_messages,
2370
2371
  chaining=self.chaining,
2371
2372
  max_chaining_steps=self.max_chaining_steps,
2372
- )
2373
+ ),
2374
+ label="multi_agent_step_thread",
2373
2375
  )
2374
2376
 
2375
2377
  if stream_steps:
@@ -53,7 +53,7 @@ from letta.services.message_manager import MessageManager
53
53
  from letta.services.source_manager import SourceManager
54
54
  from letta.services.tool_manager import ToolManager
55
55
  from letta.settings import settings
56
- from letta.utils import get_latest_alembic_revision
56
+ from letta.utils import get_latest_alembic_revision, safe_create_task
57
57
 
58
58
  logger = get_logger(__name__)
59
59
 
@@ -622,10 +622,11 @@ class AgentSerializationManager:
622
622
 
623
623
  # Create background task for file processing
624
624
  # TODO: This can be moved to celery or RQ or something
625
- task = asyncio.create_task(
625
+ task = safe_create_task(
626
626
  self._process_file_async(
627
627
  file_metadata=file_metadata, source_id=source_db_id, file_processor=file_processor, actor=actor
628
- )
628
+ ),
629
+ label=f"process_file_{file_metadata.file_name}",
629
630
  )
630
631
  background_tasks.append(task)
631
632
  logger.info(f"Started background processing for file {file_metadata.file_name} (ID: {file_db_id})")
@@ -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
 
@@ -43,7 +43,7 @@ from letta.services.mcp.stdio_client import AsyncStdioMCPClient
43
43
  from letta.services.mcp.streamable_http_client import AsyncStreamableHTTPMCPClient
44
44
  from letta.services.tool_manager import ToolManager
45
45
  from letta.settings import tool_settings
46
- from letta.utils import enforce_types, printd
46
+ from letta.utils import enforce_types, printd, safe_create_task
47
47
 
48
48
  logger = get_logger(__name__)
49
49
 
@@ -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."""
@@ -869,7 +930,7 @@ class MCPManager:
869
930
 
870
931
  # Run connect_to_server in background to avoid blocking
871
932
  # This will trigger the OAuth flow and the redirect_handler will save the authorization URL to database
872
- connect_task = asyncio.create_task(temp_client.connect_to_server())
933
+ connect_task = safe_create_task(temp_client.connect_to_server(), label="mcp_oauth_connect")
873
934
 
874
935
  # Give the OAuth flow time to trigger and save the URL
875
936
  await asyncio.sleep(1.0)
@@ -20,6 +20,7 @@ from letta.services.message_manager import MessageManager
20
20
  from letta.services.summarizer.enums import SummarizationMode
21
21
  from letta.system import package_summarize_message_no_counts
22
22
  from letta.templates.template_helper import render_template
23
+ from letta.utils import safe_create_task
23
24
 
24
25
  logger = get_logger(__name__)
25
26
 
@@ -100,7 +101,7 @@ class Summarizer:
100
101
  return in_context_messages, False
101
102
 
102
103
  def fire_and_forget(self, coro):
103
- task = asyncio.create_task(coro)
104
+ task = safe_create_task(coro, label="summarizer_background_task")
104
105
 
105
106
  def callback(t):
106
107
  try:
@@ -645,7 +645,7 @@ class LettaFileToolExecutor(ToolExecutor):
645
645
  raise e
646
646
 
647
647
  if not files_with_matches:
648
- return f"No semantic matches found in Turbopuffer for query: '{query}'"
648
+ return f"No semantic matches found for query: '{query}'"
649
649
 
650
650
  # Format results
651
651
  passage_num = 0
@@ -678,7 +678,7 @@ class LettaFileToolExecutor(ToolExecutor):
678
678
 
679
679
  # create summary header
680
680
  file_count = len(files_with_matches)
681
- summary = f"Found {total_hits} Turbopuffer matches in {file_count} file{'s' if file_count != 1 else ''} for query: '{query}'"
681
+ summary = f"Found {total_hits} matches in {file_count} file{'s' if file_count != 1 else ''} for query: '{query}'"
682
682
 
683
683
  # combine all results
684
684
  formatted_results = [summary, "=" * len(summary)] + results
@@ -13,6 +13,7 @@ from letta.schemas.tool_execution_result import ToolExecutionResult
13
13
  from letta.schemas.user import User
14
14
  from letta.services.tool_executor.tool_executor_base import ToolExecutor
15
15
  from letta.settings import settings
16
+ from letta.utils import safe_create_task
16
17
 
17
18
  logger = get_logger(__name__)
18
19
 
@@ -55,7 +56,8 @@ class LettaMultiAgentToolExecutor(ToolExecutor):
55
56
  f"{message}"
56
57
  )
57
58
 
58
- return str(await self._process_agent(agent_id=other_agent_id, message=augmented_message))
59
+ other_agent_state = await self.agent_manager.get_agent_by_id_async(agent_id=other_agent_id, actor=self.actor)
60
+ return str(await self._process_agent(agent_state=other_agent_state, message=augmented_message))
59
61
 
60
62
  async def send_message_to_agents_matching_tags_async(
61
63
  self, agent_state: AgentState, message: str, match_all: List[str], match_some: List[str]
@@ -75,22 +77,20 @@ class LettaMultiAgentToolExecutor(ToolExecutor):
75
77
  )
76
78
 
77
79
  tasks = [
78
- asyncio.create_task(self._process_agent(agent_id=agent_state.id, message=augmented_message)) for agent_state in matching_agents
80
+ safe_create_task(
81
+ self._process_agent(agent_state=agent_state, message=augmented_message), label=f"process_agent_{agent_state.id}"
82
+ )
83
+ for agent_state in matching_agents
79
84
  ]
80
85
  results = await asyncio.gather(*tasks)
81
86
  return str(results)
82
87
 
83
- async def _process_agent(self, agent_id: str, message: str) -> Dict[str, Any]:
84
- from letta.agents.letta_agent import LettaAgent
88
+ async def _process_agent(self, agent_state: AgentState, message: str) -> Dict[str, Any]:
89
+ from letta.agents.letta_agent_v2 import LettaAgentV2
85
90
 
86
91
  try:
87
- letta_agent = LettaAgent(
88
- agent_id=agent_id,
89
- message_manager=self.message_manager,
90
- agent_manager=self.agent_manager,
91
- block_manager=self.block_manager,
92
- job_manager=self.job_manager,
93
- passage_manager=self.passage_manager,
92
+ letta_agent = LettaAgentV2(
93
+ agent_state=agent_state,
94
94
  actor=self.actor,
95
95
  )
96
96
 
@@ -100,13 +100,13 @@ class LettaMultiAgentToolExecutor(ToolExecutor):
100
100
  send_message_content = [message.content for message in messages if isinstance(message, AssistantMessage)]
101
101
 
102
102
  return {
103
- "agent_id": agent_id,
103
+ "agent_id": agent_state.id,
104
104
  "response": send_message_content if send_message_content else ["<no response>"],
105
105
  }
106
106
 
107
107
  except Exception as e:
108
108
  return {
109
- "agent_id": agent_id,
109
+ "agent_id": agent_state.id,
110
110
  "error": str(e),
111
111
  "type": type(e).__name__,
112
112
  }
@@ -123,7 +123,10 @@ class LettaMultiAgentToolExecutor(ToolExecutor):
123
123
  f"{message}"
124
124
  )
125
125
 
126
- task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed))
126
+ other_agent_state = await self.agent_manager.get_agent_by_id_async(agent_id=other_agent_id, actor=self.actor)
127
+ task = safe_create_task(
128
+ self._process_agent(agent_state=other_agent_state, message=prefixed), label=f"send_message_to_{other_agent_id}"
129
+ )
127
130
 
128
131
  task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None))
129
132
 
@@ -23,7 +23,7 @@ from letta.services.helpers.tool_execution_helper import (
23
23
  from letta.services.helpers.tool_parser_helper import parse_stdout_best_effort
24
24
  from letta.services.tool_sandbox.base import AsyncToolSandboxBase
25
25
  from letta.settings import tool_settings
26
- from letta.utils import get_friendly_error_msg, parse_stderr_error_msg
26
+ from letta.utils import get_friendly_error_msg, parse_stderr_error_msg, safe_create_task
27
27
 
28
28
  logger = get_logger(__name__)
29
29
 
@@ -89,7 +89,7 @@ class AsyncToolSandboxLocal(AsyncToolSandboxBase):
89
89
  venv_preparation_task = None
90
90
  if use_venv:
91
91
  venv_path = str(os.path.join(sandbox_dir, local_configs.venv_name))
92
- venv_preparation_task = asyncio.create_task(self._prepare_venv(local_configs, venv_path, env))
92
+ venv_preparation_task = safe_create_task(self._prepare_venv(local_configs, venv_path, env), label="prepare_venv")
93
93
 
94
94
  # Generate and write execution script (always with markers, since we rely on stdout)
95
95
  code = await self.generate_execution_script(agent_state=agent_state, wrap_print_with_markers=True)
@@ -16,6 +16,7 @@ from letta.log import get_logger
16
16
  from letta.schemas.tool import ToolUpdate
17
17
  from letta.services.tool_manager import ToolManager
18
18
  from letta.services.tool_sandbox.modal_constants import CACHE_TTL_SECONDS, DEFAULT_CONFIG_KEY, MODAL_DEPLOYMENTS_KEY
19
+ from letta.utils import safe_create_task
19
20
 
20
21
  logger = get_logger(__name__)
21
22
 
@@ -197,7 +198,7 @@ class ModalVersionManager:
197
198
  if deployment_key in self._deployments_in_progress:
198
199
  self._deployments_in_progress[deployment_key].set()
199
200
  # Clean up after a short delay to allow waiters to wake up
200
- asyncio.create_task(self._cleanup_deployment_marker(deployment_key))
201
+ safe_create_task(self._cleanup_deployment_marker(deployment_key), label=f"cleanup_deployment_{deployment_key}")
201
202
 
202
203
  async def _cleanup_deployment_marker(self, deployment_key: str):
203
204
  """Clean up deployment marker after a delay."""
letta/streaming_utils.py CHANGED
@@ -99,6 +99,15 @@ class JSONInnerThoughtsExtractor:
99
99
  else:
100
100
  updates_main_json += c
101
101
  self.main_buffer += c
102
+ # NOTE (fix): Streaming JSON can arrive token-by-token from the LLM.
103
+ # In the old implementation we pre-inserted an opening quote after every
104
+ # key's colon (i.e. we emitted '"key":"' immediately). That implicitly
105
+ # assumed all values are strings. When a non-string value (e.g. true/false,
106
+ # numbers, null, or a nested object/array) streamed in next, the stream
107
+ # ended up with an unmatched '"' and appeared as a "missing end-quote" to
108
+ # clients. We now only emit an opening quote when we actually enter a
109
+ # string value (see below). This keeps values like booleans unquoted and
110
+ # avoids generating dangling quotes mid-stream.
102
111
  elif c == '"':
103
112
  if not self.escaped:
104
113
  self.in_string = not self.in_string
@@ -112,6 +121,14 @@ class JSONInnerThoughtsExtractor:
112
121
  self.main_buffer += self.main_json_held_buffer
113
122
  self.main_json_held_buffer = ""
114
123
  self.hold_main_json = False
124
+ elif self.state == "value":
125
+ # Opening quote for a string value (non-inner-thoughts only)
126
+ if not self.is_inner_thoughts_value:
127
+ if self.hold_main_json:
128
+ self.main_json_held_buffer += '"'
129
+ else:
130
+ updates_main_json += '"'
131
+ self.main_buffer += '"'
115
132
  else:
116
133
  if self.state == "key":
117
134
  self.state = "colon"
@@ -156,18 +173,26 @@ class JSONInnerThoughtsExtractor:
156
173
  updates_main_json += c
157
174
  self.main_buffer += c
158
175
  else:
176
+ # NOTE (fix): Do NOT pre-insert an opening quote after ':' any more.
177
+ # The value may not be a string; we only emit quotes when we actually
178
+ # see a string begin (handled in the '"' branch above). This prevents
179
+ # forced-quoting of non-string values and eliminates the common
180
+ # streaming artifact of "... 'request_heartbeat':'true}" missing the
181
+ # final quote.
159
182
  if c == ":" and self.state == "colon":
183
+ # Transition to reading a value; don't pre-insert quotes
160
184
  self.state = "value"
161
185
  self.is_inner_thoughts_value = self.current_key == self.inner_thoughts_key
162
186
  if self.is_inner_thoughts_value:
163
- pass # Do not include 'inner_thoughts' key in main_json
187
+ # Do not include 'inner_thoughts' key in main_json
188
+ pass
164
189
  else:
165
190
  key_colon = f'"{self.current_key}":'
166
191
  if self.hold_main_json:
167
- self.main_json_held_buffer += key_colon + '"'
192
+ self.main_json_held_buffer += key_colon
168
193
  else:
169
- updates_main_json += key_colon + '"'
170
- self.main_buffer += key_colon + '"'
194
+ updates_main_json += key_colon
195
+ self.main_buffer += key_colon
171
196
  elif c == "," and self.state == "comma_or_end":
172
197
  if self.is_inner_thoughts_value:
173
198
  # Inner thoughts value ended