letta-nightly 0.11.7.dev20250909104137__py3-none-any.whl → 0.11.7.dev20250911104039__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 (86) hide show
  1. letta/adapters/letta_llm_adapter.py +81 -0
  2. letta/adapters/letta_llm_request_adapter.py +113 -0
  3. letta/adapters/letta_llm_stream_adapter.py +171 -0
  4. letta/agents/agent_loop.py +23 -0
  5. letta/agents/base_agent.py +4 -1
  6. letta/agents/base_agent_v2.py +68 -0
  7. letta/agents/helpers.py +3 -5
  8. letta/agents/letta_agent.py +23 -12
  9. letta/agents/letta_agent_v2.py +1221 -0
  10. letta/agents/voice_agent.py +2 -1
  11. letta/constants.py +1 -1
  12. letta/errors.py +12 -0
  13. letta/functions/function_sets/base.py +53 -12
  14. letta/functions/helpers.py +3 -2
  15. letta/functions/schema_generator.py +1 -1
  16. letta/groups/sleeptime_multi_agent_v2.py +4 -2
  17. letta/groups/sleeptime_multi_agent_v3.py +233 -0
  18. letta/helpers/tool_rule_solver.py +4 -0
  19. letta/helpers/tpuf_client.py +607 -34
  20. letta/interfaces/anthropic_streaming_interface.py +74 -30
  21. letta/interfaces/openai_streaming_interface.py +80 -37
  22. letta/llm_api/google_vertex_client.py +1 -1
  23. letta/llm_api/openai_client.py +45 -4
  24. letta/orm/agent.py +4 -1
  25. letta/orm/block.py +2 -0
  26. letta/orm/blocks_agents.py +1 -0
  27. letta/orm/group.py +1 -0
  28. letta/orm/source.py +8 -1
  29. letta/orm/sources_agents.py +2 -1
  30. letta/orm/step_metrics.py +10 -0
  31. letta/orm/tools_agents.py +5 -2
  32. letta/schemas/block.py +4 -0
  33. letta/schemas/enums.py +1 -0
  34. letta/schemas/group.py +8 -0
  35. letta/schemas/letta_message.py +1 -1
  36. letta/schemas/letta_request.py +2 -2
  37. letta/schemas/mcp.py +9 -1
  38. letta/schemas/message.py +42 -2
  39. letta/schemas/providers/ollama.py +1 -1
  40. letta/schemas/providers.py +1 -2
  41. letta/schemas/source.py +6 -0
  42. letta/schemas/step_metrics.py +2 -0
  43. letta/server/rest_api/interface.py +34 -2
  44. letta/server/rest_api/json_parser.py +2 -0
  45. letta/server/rest_api/redis_stream_manager.py +2 -1
  46. letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +4 -2
  47. letta/server/rest_api/routers/v1/__init__.py +2 -0
  48. letta/server/rest_api/routers/v1/agents.py +132 -170
  49. letta/server/rest_api/routers/v1/blocks.py +6 -0
  50. letta/server/rest_api/routers/v1/folders.py +25 -7
  51. letta/server/rest_api/routers/v1/groups.py +6 -0
  52. letta/server/rest_api/routers/v1/internal_templates.py +218 -12
  53. letta/server/rest_api/routers/v1/messages.py +14 -19
  54. letta/server/rest_api/routers/v1/runs.py +43 -28
  55. letta/server/rest_api/routers/v1/sources.py +25 -7
  56. letta/server/rest_api/routers/v1/tools.py +42 -0
  57. letta/server/rest_api/streaming_response.py +11 -2
  58. letta/server/server.py +9 -6
  59. letta/services/agent_manager.py +39 -59
  60. letta/services/agent_serialization_manager.py +26 -11
  61. letta/services/archive_manager.py +60 -9
  62. letta/services/block_manager.py +5 -0
  63. letta/services/file_processor/embedder/base_embedder.py +5 -0
  64. letta/services/file_processor/embedder/openai_embedder.py +4 -0
  65. letta/services/file_processor/embedder/pinecone_embedder.py +5 -1
  66. letta/services/file_processor/embedder/turbopuffer_embedder.py +71 -0
  67. letta/services/file_processor/file_processor.py +9 -7
  68. letta/services/group_manager.py +74 -11
  69. letta/services/mcp_manager.py +134 -28
  70. letta/services/message_manager.py +229 -125
  71. letta/services/passage_manager.py +2 -1
  72. letta/services/source_manager.py +23 -1
  73. letta/services/summarizer/summarizer.py +4 -1
  74. letta/services/tool_executor/core_tool_executor.py +2 -120
  75. letta/services/tool_executor/files_tool_executor.py +133 -8
  76. letta/services/tool_executor/multi_agent_tool_executor.py +17 -14
  77. letta/services/tool_sandbox/local_sandbox.py +2 -2
  78. letta/services/tool_sandbox/modal_version_manager.py +2 -1
  79. letta/settings.py +6 -0
  80. letta/streaming_utils.py +29 -4
  81. letta/utils.py +106 -4
  82. {letta_nightly-0.11.7.dev20250909104137.dist-info → letta_nightly-0.11.7.dev20250911104039.dist-info}/METADATA +2 -2
  83. {letta_nightly-0.11.7.dev20250909104137.dist-info → letta_nightly-0.11.7.dev20250911104039.dist-info}/RECORD +86 -78
  84. {letta_nightly-0.11.7.dev20250909104137.dist-info → letta_nightly-0.11.7.dev20250911104039.dist-info}/WHEEL +0 -0
  85. {letta_nightly-0.11.7.dev20250909104137.dist-info → letta_nightly-0.11.7.dev20250911104039.dist-info}/entry_points.txt +0 -0
  86. {letta_nightly-0.11.7.dev20250909104137.dist-info → letta_nightly-0.11.7.dev20250911104039.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,7 @@
1
+ from datetime import datetime
1
2
  from typing import List, Optional, Union
2
3
 
3
- from sqlalchemy import delete, select
4
+ from sqlalchemy import and_, asc, delete, desc, or_, select
4
5
  from sqlalchemy.orm import Session
5
6
 
6
7
  from letta.orm.agent import Agent as AgentModel
@@ -13,6 +14,7 @@ from letta.schemas.letta_message import LettaMessage
13
14
  from letta.schemas.message import Message as PydanticMessage
14
15
  from letta.schemas.user import User as PydanticUser
15
16
  from letta.server.db import db_registry
17
+ from letta.settings import DatabaseChoice, settings
16
18
  from letta.utils import enforce_types
17
19
 
18
20
 
@@ -27,20 +29,34 @@ class GroupManager:
27
29
  before: Optional[str] = None,
28
30
  after: Optional[str] = None,
29
31
  limit: Optional[int] = 50,
32
+ show_hidden_groups: Optional[bool] = None,
30
33
  ) -> list[PydanticGroup]:
31
34
  async with db_registry.async_session() as session:
32
- filters = {"organization_id": actor.organization_id}
35
+ from sqlalchemy import select
36
+
37
+ from letta.orm.sqlalchemy_base import AccessType
38
+
39
+ query = select(GroupModel)
40
+ query = GroupModel.apply_access_predicate(query, actor, ["read"], AccessType.ORGANIZATION)
41
+
42
+ # Apply filters
33
43
  if project_id:
34
- filters["project_id"] = project_id
44
+ query = query.where(GroupModel.project_id == project_id)
35
45
  if manager_type:
36
- filters["manager_type"] = manager_type
37
- groups = await GroupModel.list_async(
38
- db_session=session,
39
- before=before,
40
- after=after,
41
- limit=limit,
42
- **filters,
43
- )
46
+ query = query.where(GroupModel.manager_type == manager_type)
47
+
48
+ # Apply hidden filter
49
+ if not show_hidden_groups:
50
+ query = query.where((GroupModel.hidden.is_(None)) | (GroupModel.hidden == False))
51
+
52
+ # Apply pagination
53
+ query = await _apply_group_pagination_async(query, before, after, session, ascending=True)
54
+
55
+ if limit:
56
+ query = query.limit(limit)
57
+
58
+ result = await session.execute(query)
59
+ groups = result.scalars().all()
44
60
  return [group.to_pydantic() for group in groups]
45
61
 
46
62
  @enforce_types
@@ -561,3 +577,50 @@ class GroupManager:
561
577
  # 3) ordering
562
578
  if max_value <= min_value:
563
579
  raise ValueError(f"'{max_name}' must be greater than '{min_name}' (got {max_name}={max_value} <= {min_name}={min_value})")
580
+
581
+
582
+ def _cursor_filter(sort_col, id_col, ref_sort_col, ref_id, forward: bool):
583
+ """
584
+ Returns a SQLAlchemy filter expression for cursor-based pagination for groups.
585
+
586
+ If `forward` is True, returns records after the reference.
587
+ If `forward` is False, returns records before the reference.
588
+ """
589
+ if forward:
590
+ return or_(
591
+ sort_col > ref_sort_col,
592
+ and_(sort_col == ref_sort_col, id_col > ref_id),
593
+ )
594
+ else:
595
+ return or_(
596
+ sort_col < ref_sort_col,
597
+ and_(sort_col == ref_sort_col, id_col < ref_id),
598
+ )
599
+
600
+
601
+ async def _apply_group_pagination_async(query, before: Optional[str], after: Optional[str], session, ascending: bool = True) -> any:
602
+ """Apply cursor-based pagination to group queries."""
603
+ sort_column = GroupModel.created_at
604
+
605
+ if after:
606
+ result = (await session.execute(select(sort_column, GroupModel.id).where(GroupModel.id == after))).first()
607
+ if result:
608
+ after_sort_value, after_id = result
609
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
610
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(after_sort_value, datetime):
611
+ after_sort_value = after_sort_value.strftime("%Y-%m-%d %H:%M:%S")
612
+ query = query.where(_cursor_filter(sort_column, GroupModel.id, after_sort_value, after_id, forward=ascending))
613
+
614
+ if before:
615
+ result = (await session.execute(select(sort_column, GroupModel.id).where(GroupModel.id == before))).first()
616
+ if result:
617
+ before_sort_value, before_id = result
618
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
619
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(before_sort_value, datetime):
620
+ before_sort_value = before_sort_value.strftime("%Y-%m-%d %H:%M:%S")
621
+ query = query.where(_cursor_filter(sort_column, GroupModel.id, before_sort_value, before_id, forward=not ascending))
622
+
623
+ # Apply ordering
624
+ order_fn = asc if ascending else desc
625
+ query = query.order_by(order_fn(sort_column), order_fn(GroupModel.id))
626
+ return query
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta
6
6
  from typing import Any, Dict, List, Optional, Tuple, Union
7
7
 
8
8
  from fastapi import HTTPException
9
- from sqlalchemy import delete, null
9
+ from sqlalchemy import delete, desc, null, select
10
10
  from starlette.requests import Request
11
11
 
12
12
  import letta.constants as constants
@@ -23,17 +23,19 @@ from letta.log import get_logger
23
23
  from letta.orm.errors import NoResultFound
24
24
  from letta.orm.mcp_oauth import MCPOAuth, OAuthSessionStatus
25
25
  from letta.orm.mcp_server import MCPServer as MCPServerModel
26
+ from letta.orm.tool import Tool as ToolModel
26
27
  from letta.schemas.mcp import (
27
28
  MCPOAuthSession,
28
29
  MCPOAuthSessionCreate,
29
30
  MCPOAuthSessionUpdate,
30
31
  MCPServer,
32
+ MCPServerResyncResult,
31
33
  UpdateMCPServer,
32
34
  UpdateSSEMCPServer,
33
35
  UpdateStdioMCPServer,
34
36
  UpdateStreamableHTTPMCPServer,
35
37
  )
36
- from letta.schemas.tool import Tool as PydanticTool, ToolCreate
38
+ from letta.schemas.tool import Tool as PydanticTool, ToolCreate, ToolUpdate
37
39
  from letta.schemas.user import User as PydanticUser
38
40
  from letta.server.db import db_registry
39
41
  from letta.services.mcp.sse_client import MCP_CONFIG_TOPLEVEL_KEY, AsyncSSEMCPClient
@@ -41,7 +43,7 @@ from letta.services.mcp.stdio_client import AsyncStdioMCPClient
41
43
  from letta.services.mcp.streamable_http_client import AsyncStreamableHTTPMCPClient
42
44
  from letta.services.tool_manager import ToolManager
43
45
  from letta.settings import tool_settings
44
- from letta.utils import enforce_types, printd
46
+ from letta.utils import enforce_types, printd, safe_create_task
45
47
 
46
48
  logger = get_logger(__name__)
47
49
 
@@ -147,6 +149,117 @@ class MCPManager:
147
149
  # failed to add - handle error?
148
150
  return None
149
151
 
152
+ @enforce_types
153
+ async def resync_mcp_server_tools(
154
+ self, mcp_server_name: str, actor: PydanticUser, agent_id: Optional[str] = None
155
+ ) -> MCPServerResyncResult:
156
+ """
157
+ Resync tools for an MCP server by:
158
+ 1. Fetching current tools from the MCP server
159
+ 2. Deleting tools that no longer exist on the server
160
+ 3. Updating schemas for existing tools
161
+ 4. Adding new tools from the server
162
+
163
+ Returns a result with:
164
+ - deleted: List of deleted tool names
165
+ - updated: List of updated tool names
166
+ - added: List of added tool names
167
+ """
168
+ # Get the MCP server ID
169
+ mcp_server_id = await self.get_mcp_server_id_by_name(mcp_server_name, actor=actor)
170
+ if not mcp_server_id:
171
+ raise ValueError(f"MCP server '{mcp_server_name}' not found")
172
+
173
+ # Fetch current tools from MCP server
174
+ try:
175
+ current_mcp_tools = await self.list_mcp_server_tools(mcp_server_name, actor=actor, agent_id=agent_id)
176
+ except Exception as e:
177
+ logger.error(f"Failed to fetch tools from MCP server {mcp_server_name}: {e}")
178
+ raise HTTPException(
179
+ status_code=404,
180
+ detail={
181
+ "code": "MCPServerUnavailable",
182
+ "message": f"Could not connect to MCP server {mcp_server_name} to resync tools",
183
+ "error": str(e),
184
+ },
185
+ )
186
+
187
+ # Get all persisted tools for this MCP server
188
+ async with db_registry.async_session() as session:
189
+ # Query for tools with MCP metadata matching this server
190
+ # Using JSON path query to filter by metadata
191
+ persisted_tools = await ToolModel.list_async(
192
+ db_session=session,
193
+ organization_id=actor.organization_id,
194
+ )
195
+
196
+ # Filter tools that belong to this MCP server
197
+ mcp_tools = []
198
+ for tool in persisted_tools:
199
+ if tool.metadata_ and constants.MCP_TOOL_TAG_NAME_PREFIX in tool.metadata_:
200
+ if tool.metadata_[constants.MCP_TOOL_TAG_NAME_PREFIX].get("server_id") == mcp_server_id:
201
+ mcp_tools.append(tool)
202
+
203
+ # Create maps for easier comparison
204
+ current_tool_map = {tool.name: tool for tool in current_mcp_tools}
205
+ persisted_tool_map = {tool.name: tool for tool in mcp_tools}
206
+
207
+ deleted_tools = []
208
+ updated_tools = []
209
+ added_tools = []
210
+
211
+ # 1. Delete tools that no longer exist on the server
212
+ for tool_name, persisted_tool in persisted_tool_map.items():
213
+ if tool_name not in current_tool_map:
214
+ # Delete the tool (cascade will handle agent detachment)
215
+ await persisted_tool.hard_delete_async(db_session=session, actor=actor)
216
+ deleted_tools.append(tool_name)
217
+ logger.info(f"Deleted MCP tool {tool_name} as it no longer exists on server {mcp_server_name}")
218
+
219
+ # Commit deletions
220
+ await session.commit()
221
+
222
+ # 2. Update existing tools and add new tools
223
+ for tool_name, current_tool in current_tool_map.items():
224
+ if tool_name in persisted_tool_map:
225
+ # Update existing tool
226
+ persisted_tool = persisted_tool_map[tool_name]
227
+ tool_create = ToolCreate.from_mcp(mcp_server_name=mcp_server_name, mcp_tool=current_tool)
228
+
229
+ # Check if schema has changed
230
+ if persisted_tool.json_schema != tool_create.json_schema:
231
+ # Update the tool
232
+ update_data = ToolUpdate(
233
+ description=tool_create.description,
234
+ json_schema=tool_create.json_schema,
235
+ source_code=tool_create.source_code,
236
+ )
237
+
238
+ await self.tool_manager.update_tool_by_id_async(tool_id=persisted_tool.id, tool_update=update_data, actor=actor)
239
+ updated_tools.append(tool_name)
240
+ logger.info(f"Updated MCP tool {tool_name} with new schema from server {mcp_server_name}")
241
+ else:
242
+ # Add new tool
243
+ # Skip INVALID tools
244
+ if current_tool.health and current_tool.health.status == "INVALID":
245
+ logger.warning(
246
+ f"Skipping invalid tool {tool_name} from MCP server {mcp_server_name}: {', '.join(current_tool.health.reasons)}"
247
+ )
248
+ continue
249
+
250
+ tool_create = ToolCreate.from_mcp(mcp_server_name=mcp_server_name, mcp_tool=current_tool)
251
+ await self.tool_manager.create_mcp_tool_async(
252
+ tool_create=tool_create, mcp_server_name=mcp_server_name, mcp_server_id=mcp_server_id, actor=actor
253
+ )
254
+ added_tools.append(tool_name)
255
+ logger.info(f"Added new MCP tool {tool_name} from server {mcp_server_name}")
256
+
257
+ return MCPServerResyncResult(
258
+ deleted=deleted_tools,
259
+ updated=updated_tools,
260
+ added=added_tools,
261
+ )
262
+
150
263
  @enforce_types
151
264
  async def list_mcp_servers(self, actor: PydanticUser) -> List[MCPServer]:
152
265
  """List all MCP servers available"""
@@ -209,8 +322,6 @@ class MCPManager:
209
322
  # This ensures OAuth sessions created during testing get linked to the server
210
323
  server_url = getattr(mcp_server, "server_url", None)
211
324
  if server_url:
212
- from sqlalchemy import select
213
-
214
325
  result = await session.execute(
215
326
  select(MCPOAuth).where(
216
327
  MCPOAuth.server_url == server_url,
@@ -326,26 +437,9 @@ class MCPManager:
326
437
  )
327
438
  return mcp_server.to_pydantic()
328
439
 
329
- # @enforce_types
330
- # async def delete_mcp_server(self, mcp_server_name: str, actor: PydanticUser) -> None:
331
- # """Delete an existing tool."""
332
- # with db_registry.session() as session:
333
- # mcp_server_id = await self.get_mcp_server_id_by_name(mcp_server_name, actor)
334
- # mcp_server = await MCPServerModel.read_async(db_session=session, identifier=mcp_server_id, actor=actor)
335
- # if not mcp_server:
336
- # raise HTTPException(
337
- # status_code=404, # Not Found
338
- # detail={
339
- # "code": "MCPServerNotFoundError",
340
- # "message": f"MCP server {mcp_server_name} not found",
341
- # "mcp_server_name": mcp_server_name,
342
- # },
343
- # )
344
- # mcp_server.delete(session, actor=actor) # Re-raise other database-related errors
345
-
346
440
  @enforce_types
347
441
  async def delete_mcp_server_by_id(self, mcp_server_id: str, actor: PydanticUser) -> None:
348
- """Delete a MCP server by its ID."""
442
+ """Delete a MCP server by its ID and associated tools and OAuth sessions."""
349
443
  async with db_registry.async_session() as session:
350
444
  try:
351
445
  mcp_server = await MCPServerModel.read_async(db_session=session, identifier=mcp_server_id, actor=actor)
@@ -353,6 +447,22 @@ class MCPManager:
353
447
  raise NoResultFound(f"MCP server with id {mcp_server_id} not found.")
354
448
 
355
449
  server_url = getattr(mcp_server, "server_url", None)
450
+ # Get all tools with matching metadata
451
+ stmt = select(ToolModel).where(ToolModel.organization_id == actor.organization_id)
452
+ result = await session.execute(stmt)
453
+ all_tools = result.scalars().all()
454
+
455
+ # Filter and delete tools that belong to this MCP server
456
+ tools_deleted = 0
457
+ for tool in all_tools:
458
+ if tool.metadata_ and constants.MCP_TOOL_TAG_NAME_PREFIX in tool.metadata_:
459
+ if tool.metadata_[constants.MCP_TOOL_TAG_NAME_PREFIX].get("server_id") == mcp_server_id:
460
+ await tool.hard_delete_async(db_session=session, actor=actor)
461
+ tools_deleted = 1
462
+ logger.info(f"Deleted MCP tool {tool.name} associated with MCP server {mcp_server_id}")
463
+
464
+ if tools_deleted > 0:
465
+ logger.info(f"Deleted {tools_deleted} MCP tools associated with MCP server {mcp_server_id}")
356
466
 
357
467
  # Delete OAuth sessions for the same user and server URL in the same transaction
358
468
  # This handles orphaned sessions that were created during testing/connection
@@ -557,8 +667,6 @@ class MCPManager:
557
667
  @enforce_types
558
668
  async def get_oauth_session_by_server(self, server_url: str, actor: PydanticUser) -> Optional[MCPOAuthSession]:
559
669
  """Get the latest OAuth session by server URL, organization, and user."""
560
- from sqlalchemy import desc, select
561
-
562
670
  async with db_registry.async_session() as session:
563
671
  # Query for OAuth session matching organization, user, server URL, and status
564
672
  # Order by updated_at desc to get the most recent record
@@ -673,8 +781,6 @@ class MCPManager:
673
781
  cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
674
782
 
675
783
  async with db_registry.async_session() as session:
676
- from sqlalchemy import select
677
-
678
784
  # Find expired sessions
679
785
  result = await session.execute(select(MCPOAuth).where(MCPOAuth.created_at < cutoff_time))
680
786
  expired_sessions = result.scalars().all()
@@ -763,7 +869,7 @@ class MCPManager:
763
869
 
764
870
  # Run connect_to_server in background to avoid blocking
765
871
  # This will trigger the OAuth flow and the redirect_handler will save the authorization URL to database
766
- connect_task = asyncio.create_task(temp_client.connect_to_server())
872
+ connect_task = safe_create_task(temp_client.connect_to_server(), label="mcp_oauth_connect")
767
873
 
768
874
  # Give the OAuth flow time to trigger and save the URL
769
875
  await asyncio.sleep(1.0)