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.
- letta/adapters/letta_llm_request_adapter.py +4 -2
- letta/adapters/letta_llm_stream_adapter.py +4 -2
- letta/agents/agent_loop.py +23 -0
- letta/agents/letta_agent_v2.py +34 -12
- letta/functions/helpers.py +3 -2
- letta/groups/sleeptime_multi_agent_v2.py +4 -2
- letta/groups/sleeptime_multi_agent_v3.py +4 -2
- letta/helpers/tpuf_client.py +41 -9
- letta/interfaces/anthropic_streaming_interface.py +10 -6
- letta/interfaces/openai_streaming_interface.py +9 -74
- letta/llm_api/google_vertex_client.py +6 -1
- letta/llm_api/openai_client.py +9 -8
- letta/orm/agent.py +4 -1
- letta/orm/block.py +1 -0
- letta/orm/blocks_agents.py +1 -0
- letta/orm/job.py +5 -1
- letta/orm/organization.py +2 -0
- letta/orm/sources_agents.py +2 -1
- letta/orm/tools_agents.py +5 -2
- letta/schemas/message.py +19 -2
- letta/server/rest_api/interface.py +34 -2
- letta/server/rest_api/json_parser.py +2 -0
- letta/server/rest_api/redis_stream_manager.py +17 -3
- letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +4 -2
- letta/server/rest_api/routers/v1/agents.py +49 -180
- letta/server/rest_api/routers/v1/folders.py +2 -2
- letta/server/rest_api/routers/v1/sources.py +2 -2
- letta/server/rest_api/routers/v1/tools.py +23 -39
- letta/server/rest_api/streaming_response.py +2 -1
- letta/server/server.py +7 -5
- letta/services/agent_serialization_manager.py +4 -3
- letta/services/job_manager.py +5 -2
- letta/services/mcp_manager.py +66 -5
- letta/services/summarizer/summarizer.py +2 -1
- letta/services/tool_executor/files_tool_executor.py +2 -2
- letta/services/tool_executor/multi_agent_tool_executor.py +17 -14
- letta/services/tool_sandbox/local_sandbox.py +2 -2
- letta/services/tool_sandbox/modal_version_manager.py +2 -1
- letta/streaming_utils.py +29 -4
- letta/utils.py +72 -3
- {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/METADATA +3 -3
- {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/RECORD +45 -44
- {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20250910104051.dist-info → letta_nightly-0.11.7.dev20250912104045.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
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
|
|
@@ -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 =
|
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 =
|
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 =
|
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 =
|
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})")
|
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
|
|
letta/services/mcp_manager.py
CHANGED
@@ -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.
|
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."""
|
@@ -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 =
|
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 =
|
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
|
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}
|
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
|
-
|
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
|
-
|
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,
|
84
|
-
from letta.agents.
|
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 =
|
88
|
-
|
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":
|
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":
|
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
|
-
|
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 =
|
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
|
-
|
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
|
-
|
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
|