letta-nightly 0.7.30.dev20250603104343__py3-none-any.whl → 0.8.0.dev20250604104349__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 (136) hide show
  1. letta/__init__.py +7 -1
  2. letta/agent.py +14 -7
  3. letta/agents/base_agent.py +1 -0
  4. letta/agents/ephemeral_summary_agent.py +104 -0
  5. letta/agents/helpers.py +35 -3
  6. letta/agents/letta_agent.py +492 -176
  7. letta/agents/letta_agent_batch.py +22 -16
  8. letta/agents/prompts/summary_system_prompt.txt +62 -0
  9. letta/agents/voice_agent.py +22 -7
  10. letta/agents/voice_sleeptime_agent.py +13 -8
  11. letta/constants.py +33 -1
  12. letta/data_sources/connectors.py +52 -36
  13. letta/errors.py +4 -0
  14. letta/functions/ast_parsers.py +13 -30
  15. letta/functions/function_sets/base.py +3 -1
  16. letta/functions/functions.py +2 -0
  17. letta/functions/mcp_client/base_client.py +151 -97
  18. letta/functions/mcp_client/sse_client.py +49 -31
  19. letta/functions/mcp_client/stdio_client.py +107 -106
  20. letta/functions/schema_generator.py +22 -22
  21. letta/groups/helpers.py +3 -4
  22. letta/groups/sleeptime_multi_agent.py +4 -4
  23. letta/groups/sleeptime_multi_agent_v2.py +22 -0
  24. letta/helpers/composio_helpers.py +16 -0
  25. letta/helpers/converters.py +20 -0
  26. letta/helpers/datetime_helpers.py +1 -6
  27. letta/helpers/tool_rule_solver.py +2 -1
  28. letta/interfaces/anthropic_streaming_interface.py +17 -2
  29. letta/interfaces/openai_chat_completions_streaming_interface.py +1 -0
  30. letta/interfaces/openai_streaming_interface.py +18 -2
  31. letta/llm_api/anthropic_client.py +24 -3
  32. letta/llm_api/google_ai_client.py +0 -15
  33. letta/llm_api/google_vertex_client.py +6 -5
  34. letta/llm_api/llm_client_base.py +15 -0
  35. letta/llm_api/openai.py +2 -2
  36. letta/llm_api/openai_client.py +60 -8
  37. letta/orm/__init__.py +2 -0
  38. letta/orm/agent.py +45 -43
  39. letta/orm/base.py +0 -2
  40. letta/orm/block.py +1 -0
  41. letta/orm/custom_columns.py +13 -0
  42. letta/orm/enums.py +5 -0
  43. letta/orm/file.py +3 -1
  44. letta/orm/files_agents.py +68 -0
  45. letta/orm/mcp_server.py +48 -0
  46. letta/orm/message.py +1 -0
  47. letta/orm/organization.py +11 -2
  48. letta/orm/passage.py +25 -10
  49. letta/orm/sandbox_config.py +5 -2
  50. letta/orm/sqlalchemy_base.py +171 -110
  51. letta/prompts/system/memgpt_base.txt +6 -1
  52. letta/prompts/system/memgpt_v2_chat.txt +57 -0
  53. letta/prompts/system/sleeptime.txt +2 -0
  54. letta/prompts/system/sleeptime_v2.txt +28 -0
  55. letta/schemas/agent.py +87 -20
  56. letta/schemas/block.py +7 -1
  57. letta/schemas/file.py +57 -0
  58. letta/schemas/mcp.py +74 -0
  59. letta/schemas/memory.py +5 -2
  60. letta/schemas/message.py +9 -0
  61. letta/schemas/openai/openai.py +0 -6
  62. letta/schemas/providers.py +33 -4
  63. letta/schemas/tool.py +26 -21
  64. letta/schemas/tool_execution_result.py +5 -0
  65. letta/server/db.py +23 -8
  66. letta/server/rest_api/app.py +73 -56
  67. letta/server/rest_api/interface.py +4 -4
  68. letta/server/rest_api/routers/v1/agents.py +132 -47
  69. letta/server/rest_api/routers/v1/blocks.py +3 -2
  70. letta/server/rest_api/routers/v1/embeddings.py +3 -3
  71. letta/server/rest_api/routers/v1/groups.py +3 -3
  72. letta/server/rest_api/routers/v1/jobs.py +14 -17
  73. letta/server/rest_api/routers/v1/organizations.py +10 -10
  74. letta/server/rest_api/routers/v1/providers.py +12 -10
  75. letta/server/rest_api/routers/v1/runs.py +3 -3
  76. letta/server/rest_api/routers/v1/sandbox_configs.py +12 -12
  77. letta/server/rest_api/routers/v1/sources.py +108 -43
  78. letta/server/rest_api/routers/v1/steps.py +8 -6
  79. letta/server/rest_api/routers/v1/tools.py +134 -95
  80. letta/server/rest_api/utils.py +12 -1
  81. letta/server/server.py +272 -73
  82. letta/services/agent_manager.py +246 -313
  83. letta/services/block_manager.py +30 -9
  84. letta/services/context_window_calculator/__init__.py +0 -0
  85. letta/services/context_window_calculator/context_window_calculator.py +150 -0
  86. letta/services/context_window_calculator/token_counter.py +82 -0
  87. letta/services/file_processor/__init__.py +0 -0
  88. letta/services/file_processor/chunker/__init__.py +0 -0
  89. letta/services/file_processor/chunker/llama_index_chunker.py +29 -0
  90. letta/services/file_processor/embedder/__init__.py +0 -0
  91. letta/services/file_processor/embedder/openai_embedder.py +84 -0
  92. letta/services/file_processor/file_processor.py +123 -0
  93. letta/services/file_processor/parser/__init__.py +0 -0
  94. letta/services/file_processor/parser/base_parser.py +9 -0
  95. letta/services/file_processor/parser/mistral_parser.py +54 -0
  96. letta/services/file_processor/types.py +0 -0
  97. letta/services/files_agents_manager.py +184 -0
  98. letta/services/group_manager.py +118 -0
  99. letta/services/helpers/agent_manager_helper.py +76 -21
  100. letta/services/helpers/tool_execution_helper.py +3 -0
  101. letta/services/helpers/tool_parser_helper.py +100 -0
  102. letta/services/identity_manager.py +44 -42
  103. letta/services/job_manager.py +21 -10
  104. letta/services/mcp/base_client.py +5 -2
  105. letta/services/mcp/sse_client.py +3 -5
  106. letta/services/mcp/stdio_client.py +3 -5
  107. letta/services/mcp_manager.py +281 -0
  108. letta/services/message_manager.py +40 -26
  109. letta/services/organization_manager.py +55 -19
  110. letta/services/passage_manager.py +211 -13
  111. letta/services/provider_manager.py +48 -2
  112. letta/services/sandbox_config_manager.py +105 -0
  113. letta/services/source_manager.py +4 -5
  114. letta/services/step_manager.py +9 -6
  115. letta/services/summarizer/summarizer.py +50 -23
  116. letta/services/telemetry_manager.py +7 -0
  117. letta/services/tool_executor/tool_execution_manager.py +11 -52
  118. letta/services/tool_executor/tool_execution_sandbox.py +4 -34
  119. letta/services/tool_executor/tool_executor.py +107 -105
  120. letta/services/tool_manager.py +56 -17
  121. letta/services/tool_sandbox/base.py +39 -92
  122. letta/services/tool_sandbox/e2b_sandbox.py +16 -11
  123. letta/services/tool_sandbox/local_sandbox.py +51 -23
  124. letta/services/user_manager.py +36 -3
  125. letta/settings.py +10 -3
  126. letta/templates/__init__.py +0 -0
  127. letta/templates/sandbox_code_file.py.j2 +47 -0
  128. letta/templates/template_helper.py +16 -0
  129. letta/tracing.py +30 -1
  130. letta/types/__init__.py +7 -0
  131. letta/utils.py +25 -1
  132. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/METADATA +7 -2
  133. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/RECORD +136 -110
  134. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/LICENSE +0 -0
  135. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/WHEEL +0 -0
  136. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,184 @@
1
+ from datetime import datetime, timezone
2
+ from typing import List, Optional
3
+
4
+ from sqlalchemy import and_, func, select, update
5
+
6
+ from letta.orm.errors import NoResultFound
7
+ from letta.orm.files_agents import FileAgent as FileAgentModel
8
+ from letta.schemas.file import FileAgent as PydanticFileAgent
9
+ from letta.schemas.user import User as PydanticUser
10
+ from letta.server.db import db_registry
11
+ from letta.tracing import trace_method
12
+ from letta.utils import enforce_types
13
+
14
+
15
+ class FileAgentManager:
16
+ """High-level helpers for CRUD / listing on the `files_agents` join table."""
17
+
18
+ @enforce_types
19
+ @trace_method
20
+ async def attach_file(
21
+ self,
22
+ *,
23
+ agent_id: str,
24
+ file_id: str,
25
+ actor: PydanticUser,
26
+ is_open: bool = True,
27
+ visible_content: Optional[str] = None,
28
+ ) -> PydanticFileAgent:
29
+ """
30
+ Idempotently attach *file_id* to *agent_id*.
31
+
32
+ • If the row already exists → update `is_open`, `visible_content`
33
+ and always refresh `last_accessed_at`.
34
+ • Otherwise create a brand-new association.
35
+ """
36
+ async with db_registry.async_session() as session:
37
+ query = select(FileAgentModel).where(
38
+ and_(
39
+ FileAgentModel.agent_id == agent_id,
40
+ FileAgentModel.file_id == file_id,
41
+ FileAgentModel.organization_id == actor.organization_id,
42
+ )
43
+ )
44
+ existing = await session.scalar(query)
45
+
46
+ now_ts = datetime.now(timezone.utc)
47
+
48
+ if existing:
49
+ # update only the fields that actually changed
50
+ if existing.is_open != is_open:
51
+ existing.is_open = is_open
52
+
53
+ if visible_content is not None and existing.visible_content != visible_content:
54
+ existing.visible_content = visible_content
55
+
56
+ existing.last_accessed_at = now_ts
57
+
58
+ await existing.update_async(session, actor=actor)
59
+ return existing.to_pydantic()
60
+
61
+ assoc = FileAgentModel(
62
+ agent_id=agent_id,
63
+ file_id=file_id,
64
+ organization_id=actor.organization_id,
65
+ is_open=is_open,
66
+ visible_content=visible_content,
67
+ last_accessed_at=now_ts,
68
+ )
69
+ await assoc.create_async(session, actor=actor)
70
+ return assoc.to_pydantic()
71
+
72
+ @enforce_types
73
+ @trace_method
74
+ async def update_file_agent(
75
+ self,
76
+ *,
77
+ agent_id: str,
78
+ file_id: str,
79
+ actor: PydanticUser,
80
+ is_open: Optional[bool] = None,
81
+ visible_content: Optional[str] = None,
82
+ ) -> PydanticFileAgent:
83
+ """Patch an existing association row."""
84
+ async with db_registry.async_session() as session:
85
+ assoc = await self._get_association(session, agent_id, file_id, actor)
86
+
87
+ if is_open is not None:
88
+ assoc.is_open = is_open
89
+ if visible_content is not None:
90
+ assoc.visible_content = visible_content
91
+
92
+ # touch timestamp
93
+ assoc.last_accessed_at = datetime.now(timezone.utc)
94
+
95
+ await assoc.update_async(session, actor=actor)
96
+ return assoc.to_pydantic()
97
+
98
+ @enforce_types
99
+ @trace_method
100
+ async def detach_file(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> None:
101
+ """Hard-delete the association."""
102
+ async with db_registry.async_session() as session:
103
+ assoc = await self._get_association(session, agent_id, file_id, actor)
104
+ await assoc.hard_delete_async(session, actor=actor)
105
+
106
+ @enforce_types
107
+ @trace_method
108
+ async def get_file_agent(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> Optional[PydanticFileAgent]:
109
+ async with db_registry.async_session() as session:
110
+ try:
111
+ assoc = await self._get_association(session, agent_id, file_id, actor)
112
+ return assoc.to_pydantic()
113
+ except NoResultFound:
114
+ return None
115
+
116
+ @enforce_types
117
+ @trace_method
118
+ async def list_files_for_agent(
119
+ self,
120
+ agent_id: str,
121
+ actor: PydanticUser,
122
+ is_open_only: bool = False,
123
+ ) -> List[PydanticFileAgent]:
124
+ """Return associations for *agent_id* (filtering by `is_open` if asked)."""
125
+ async with db_registry.async_session() as session:
126
+ conditions = [
127
+ FileAgentModel.agent_id == agent_id,
128
+ FileAgentModel.organization_id == actor.organization_id,
129
+ ]
130
+ if is_open_only:
131
+ conditions.append(FileAgentModel.is_open.is_(True))
132
+
133
+ rows = (await session.execute(select(FileAgentModel).where(and_(*conditions)))).scalars().all()
134
+ return [r.to_pydantic() for r in rows]
135
+
136
+ @enforce_types
137
+ @trace_method
138
+ async def list_agents_for_file(
139
+ self,
140
+ file_id: str,
141
+ actor: PydanticUser,
142
+ is_open_only: bool = False,
143
+ ) -> List[PydanticFileAgent]:
144
+ """Return associations for *file_id* (filtering by `is_open` if asked)."""
145
+ async with db_registry.async_session() as session:
146
+ conditions = [
147
+ FileAgentModel.file_id == file_id,
148
+ FileAgentModel.organization_id == actor.organization_id,
149
+ ]
150
+ if is_open_only:
151
+ conditions.append(FileAgentModel.is_open.is_(True))
152
+
153
+ rows = (await session.execute(select(FileAgentModel).where(and_(*conditions)))).scalars().all()
154
+ return [r.to_pydantic() for r in rows]
155
+
156
+ @enforce_types
157
+ @trace_method
158
+ async def mark_access(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> None:
159
+ """Update only `last_accessed_at = now()` without loading the row."""
160
+ async with db_registry.async_session() as session:
161
+ stmt = (
162
+ update(FileAgentModel)
163
+ .where(
164
+ FileAgentModel.agent_id == agent_id,
165
+ FileAgentModel.file_id == file_id,
166
+ FileAgentModel.organization_id == actor.organization_id,
167
+ )
168
+ .values(last_accessed_at=func.now())
169
+ )
170
+ await session.execute(stmt)
171
+ await session.commit()
172
+
173
+ async def _get_association(self, session, agent_id: str, file_id: str, actor: PydanticUser) -> FileAgentModel:
174
+ q = select(FileAgentModel).where(
175
+ and_(
176
+ FileAgentModel.agent_id == agent_id,
177
+ FileAgentModel.file_id == file_id,
178
+ FileAgentModel.organization_id == actor.organization_id,
179
+ )
180
+ )
181
+ assoc = await session.scalar(q)
182
+ if not assoc:
183
+ raise NoResultFound(f"FileAgent(agent_id={agent_id}, file_id={file_id}) not found in org {actor.organization_id}")
184
+ return assoc
@@ -1,5 +1,6 @@
1
1
  from typing import List, Optional
2
2
 
3
+ from sqlalchemy import select
3
4
  from sqlalchemy.orm import Session
4
5
 
5
6
  from letta.orm.agent import Agent as AgentModel
@@ -51,6 +52,13 @@ class GroupManager:
51
52
  group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
52
53
  return group.to_pydantic()
53
54
 
55
+ @trace_method
56
+ @enforce_types
57
+ async def retrieve_group_async(self, group_id: str, actor: PydanticUser) -> PydanticGroup:
58
+ async with db_registry.async_session() as session:
59
+ group = await GroupModel.read_async(db_session=session, identifier=group_id, actor=actor)
60
+ return group.to_pydantic()
61
+
54
62
  @trace_method
55
63
  @enforce_types
56
64
  def create_group(self, group: GroupCreate, actor: PydanticUser) -> PydanticGroup:
@@ -97,6 +105,51 @@ class GroupManager:
97
105
  new_group.create(session, actor=actor)
98
106
  return new_group.to_pydantic()
99
107
 
108
+ @enforce_types
109
+ async def create_group_async(self, group: GroupCreate, actor: PydanticUser) -> PydanticGroup:
110
+ async with db_registry.async_session() as session:
111
+ new_group = GroupModel()
112
+ new_group.organization_id = actor.organization_id
113
+ new_group.description = group.description
114
+
115
+ match group.manager_config.manager_type:
116
+ case ManagerType.round_robin:
117
+ new_group.manager_type = ManagerType.round_robin
118
+ new_group.max_turns = group.manager_config.max_turns
119
+ case ManagerType.dynamic:
120
+ new_group.manager_type = ManagerType.dynamic
121
+ new_group.manager_agent_id = group.manager_config.manager_agent_id
122
+ new_group.max_turns = group.manager_config.max_turns
123
+ new_group.termination_token = group.manager_config.termination_token
124
+ case ManagerType.supervisor:
125
+ new_group.manager_type = ManagerType.supervisor
126
+ new_group.manager_agent_id = group.manager_config.manager_agent_id
127
+ case ManagerType.sleeptime:
128
+ new_group.manager_type = ManagerType.sleeptime
129
+ new_group.manager_agent_id = group.manager_config.manager_agent_id
130
+ new_group.sleeptime_agent_frequency = group.manager_config.sleeptime_agent_frequency
131
+ if new_group.sleeptime_agent_frequency:
132
+ new_group.turns_counter = -1
133
+ case ManagerType.voice_sleeptime:
134
+ new_group.manager_type = ManagerType.voice_sleeptime
135
+ new_group.manager_agent_id = group.manager_config.manager_agent_id
136
+ max_message_buffer_length = group.manager_config.max_message_buffer_length
137
+ min_message_buffer_length = group.manager_config.min_message_buffer_length
138
+ # Safety check for buffer length range
139
+ self.ensure_buffer_length_range_valid(max_value=max_message_buffer_length, min_value=min_message_buffer_length)
140
+ new_group.max_message_buffer_length = max_message_buffer_length
141
+ new_group.min_message_buffer_length = min_message_buffer_length
142
+ case _:
143
+ raise ValueError(f"Unsupported manager type: {group.manager_config.manager_type}")
144
+
145
+ await self._process_agent_relationship_async(session=session, group=new_group, agent_ids=group.agent_ids, allow_partial=False)
146
+
147
+ if group.shared_block_ids:
148
+ await self._process_shared_block_relationship_async(session=session, group=new_group, block_ids=group.shared_block_ids)
149
+
150
+ await new_group.create_async(session, actor=actor)
151
+ return new_group.to_pydantic()
152
+
100
153
  @trace_method
101
154
  @enforce_types
102
155
  def modify_group(self, group_id: str, group_update: GroupUpdate, actor: PydanticUser) -> PydanticGroup:
@@ -313,6 +366,38 @@ class GroupManager:
313
366
  else:
314
367
  raise ValueError("Extend relationship is not supported for groups.")
315
368
 
369
+ async def _process_agent_relationship_async(self, session, group: GroupModel, agent_ids: List[str], allow_partial=False, replace=True):
370
+ if not agent_ids:
371
+ if replace:
372
+ setattr(group, "agents", [])
373
+ setattr(group, "agent_ids", [])
374
+ return
375
+
376
+ if group.manager_type == ManagerType.dynamic and len(agent_ids) != len(set(agent_ids)):
377
+ raise ValueError("Duplicate agent ids found in list")
378
+
379
+ # Retrieve models for the provided IDs
380
+ query = select(AgentModel).where(AgentModel.id.in_(agent_ids))
381
+ result = await session.execute(query)
382
+ found_items = result.scalars().all()
383
+
384
+ # Validate all items are found if allow_partial is False
385
+ if not allow_partial and len(found_items) != len(agent_ids):
386
+ missing = set(agent_ids) - {item.id for item in found_items}
387
+ raise NoResultFound(f"Items not found in agents: {missing}")
388
+
389
+ if group.manager_type == ManagerType.dynamic:
390
+ names = [item.name for item in found_items]
391
+ if len(names) != len(set(names)):
392
+ raise ValueError("Duplicate agent names found in the provided agent IDs.")
393
+
394
+ if replace:
395
+ # Replace the relationship
396
+ setattr(group, "agents", found_items)
397
+ setattr(group, "agent_ids", agent_ids)
398
+ else:
399
+ raise ValueError("Extend relationship is not supported for groups.")
400
+
316
401
  def _process_shared_block_relationship(
317
402
  self,
318
403
  session: Session,
@@ -340,6 +425,39 @@ class GroupManager:
340
425
  for block in blocks:
341
426
  session.add(BlocksAgents(agent_id=manager_agent.id, block_id=block.id, block_label=block.label))
342
427
 
428
+ async def _process_shared_block_relationship_async(
429
+ self,
430
+ session,
431
+ group: GroupModel,
432
+ block_ids: List[str],
433
+ ):
434
+ """Process shared block relationships for a group and its agents."""
435
+ from letta.orm import Agent, Block, BlocksAgents
436
+
437
+ # Add blocks to group
438
+ query = select(Block).where(Block.id.in_(block_ids))
439
+ result = await session.execute(query)
440
+ blocks = result.scalars().all()
441
+ group.shared_blocks = blocks
442
+
443
+ # Add blocks to all agents
444
+ if group.agent_ids:
445
+ query = select(Agent).where(Agent.id.in_(group.agent_ids))
446
+ result = await session.execute(query)
447
+ agents = result.scalars().all()
448
+ for agent in agents:
449
+ for block in blocks:
450
+ session.add(BlocksAgents(agent_id=agent.id, block_id=block.id, block_label=block.label))
451
+
452
+ # Add blocks to manager agent if exists
453
+ if group.manager_agent_id:
454
+ query = select(Agent).where(Agent.id == group.manager_agent_id)
455
+ result = await session.execute(query)
456
+ manager_agent = result.scalar_one_or_none()
457
+ if manager_agent:
458
+ for block in blocks:
459
+ session.add(BlocksAgents(agent_id=manager_agent.id, block_id=block.id, block_label=block.label))
460
+
343
461
  @staticmethod
344
462
  def ensure_buffer_length_range_valid(
345
463
  max_value: Optional[int],
@@ -1,7 +1,8 @@
1
1
  import datetime
2
2
  from typing import List, Literal, Optional
3
3
 
4
- from sqlalchemy import and_, asc, desc, exists, or_, select
4
+ from sqlalchemy import and_, asc, desc, or_, select
5
+ from sqlalchemy.sql.expression import exists
5
6
 
6
7
  from letta import system
7
8
  from letta.constants import IN_CONTEXT_MEMORY_KEYWORD, STRUCTURED_OUTPUT_MODELS
@@ -17,7 +18,6 @@ from letta.schemas.enums import MessageRole
17
18
  from letta.schemas.letta_message_content import TextContent
18
19
  from letta.schemas.memory import Memory
19
20
  from letta.schemas.message import Message, MessageCreate
20
- from letta.schemas.passage import Passage as PydanticPassage
21
21
  from letta.schemas.tool_rule import ToolRule
22
22
  from letta.schemas.user import User
23
23
  from letta.system import get_initial_boot_messages, get_login_event, package_function_response
@@ -68,6 +68,50 @@ def _process_relationship(
68
68
  current_relationship.extend(new_items)
69
69
 
70
70
 
71
+ @trace_method
72
+ async def _process_relationship_async(
73
+ session, agent: AgentModel, relationship_name: str, model_class, item_ids: List[str], allow_partial=False, replace=True
74
+ ):
75
+ """
76
+ Generalized function to handle relationships like tools, sources, and blocks using item IDs.
77
+
78
+ Args:
79
+ session: The database session.
80
+ agent: The AgentModel instance.
81
+ relationship_name: The name of the relationship attribute (e.g., 'tools', 'sources').
82
+ model_class: The ORM class corresponding to the related items.
83
+ item_ids: List of IDs to set or update.
84
+ allow_partial: If True, allows missing items without raising errors.
85
+ replace: If True, replaces the entire relationship; otherwise, extends it.
86
+
87
+ Raises:
88
+ ValueError: If `allow_partial` is False and some IDs are missing.
89
+ """
90
+ current_relationship = getattr(agent, relationship_name, [])
91
+ if not item_ids:
92
+ if replace:
93
+ setattr(agent, relationship_name, [])
94
+ return
95
+
96
+ # Retrieve models for the provided IDs
97
+ result = await session.execute(select(model_class).where(model_class.id.in_(item_ids)))
98
+ found_items = result.scalars().all()
99
+
100
+ # Validate all items are found if allow_partial is False
101
+ if not allow_partial and len(found_items) != len(item_ids):
102
+ missing = set(item_ids) - {item.id for item in found_items}
103
+ raise NoResultFound(f"Items not found in {relationship_name}: {missing}")
104
+
105
+ if replace:
106
+ # Replace the relationship
107
+ setattr(agent, relationship_name, found_items)
108
+ else:
109
+ # Extend the relationship (only add new items)
110
+ current_ids = {item.id for item in current_relationship}
111
+ new_items = [item for item in found_items if item.id not in current_ids]
112
+ current_relationship.extend(new_items)
113
+
114
+
71
115
  def _process_tags(agent: AgentModel, tags: List[str], replace=True):
72
116
  """
73
117
  Handles tags for an agent.
@@ -94,16 +138,32 @@ def _process_tags(agent: AgentModel, tags: List[str], replace=True):
94
138
  def derive_system_message(agent_type: AgentType, enable_sleeptime: Optional[bool] = None, system: Optional[str] = None):
95
139
  if system is None:
96
140
  # TODO: don't hardcode
141
+
97
142
  if agent_type == AgentType.voice_convo_agent:
98
143
  system = gpt_system.get_system_text("voice_chat")
144
+
99
145
  elif agent_type == AgentType.voice_sleeptime_agent:
100
146
  system = gpt_system.get_system_text("voice_sleeptime")
147
+
148
+ # MemGPT v1, both w/ and w/o sleeptime
101
149
  elif agent_type == AgentType.memgpt_agent and not enable_sleeptime:
102
- system = gpt_system.get_system_text("memgpt_chat")
150
+ system = gpt_system.get_system_text("memgpt_v2_chat")
103
151
  elif agent_type == AgentType.memgpt_agent and enable_sleeptime:
104
- system = gpt_system.get_system_text("memgpt_sleeptime_chat")
152
+ # NOTE: same as the chat one, since the chat one says that you "may" have the tools
153
+ system = gpt_system.get_system_text("memgpt_v2_chat")
154
+
155
+ # MemGPT v2, both w/ and w/o sleeptime
156
+ elif agent_type == AgentType.memgpt_v2_agent and not enable_sleeptime:
157
+ system = gpt_system.get_system_text("memgpt_v2_chat")
158
+ elif agent_type == AgentType.memgpt_v2_agent and enable_sleeptime:
159
+ # NOTE: same as the chat one, since the chat one says that you "may" have the tools
160
+ system = gpt_system.get_system_text("memgpt_v2_chat")
161
+
162
+ # Sleeptime
105
163
  elif agent_type == AgentType.sleeptime_agent:
106
- system = gpt_system.get_system_text("sleeptime")
164
+ # v2 drops references to specific blocks, and instead relies on the block description injections
165
+ system = gpt_system.get_system_text("sleeptime_v2")
166
+
107
167
  else:
108
168
  raise ValueError(f"Invalid agent type: {agent_type}")
109
169
 
@@ -115,7 +175,6 @@ def compile_memory_metadata_block(
115
175
  memory_edit_timestamp: datetime.datetime,
116
176
  previous_message_count: int = 0,
117
177
  archival_memory_size: int = 0,
118
- recent_passages: List[PydanticPassage] = None,
119
178
  ) -> str:
120
179
  # Put the timestamp in the local timezone (mimicking get_local_time())
121
180
  timestamp_str = memory_edit_timestamp.astimezone().strftime("%Y-%m-%d %I:%M:%S %p %Z%z").strip()
@@ -123,15 +182,12 @@ def compile_memory_metadata_block(
123
182
  # Create a metadata block of info so the agent knows about the metadata of out-of-context memories
124
183
  memory_metadata_block = "\n".join(
125
184
  [
126
- f"### Current Time: {get_local_time_fast()}" f"### Memory [last modified: {timestamp_str}]",
127
- f"{previous_message_count} previous messages between you and the user are stored in recall memory (use functions to access them)",
128
- f"{archival_memory_size} total memories you created are stored in archival memory (use functions to access them)",
129
- (
130
- f"Most recent archival passages {len(recent_passages)} recent passages: {[passage.text for passage in recent_passages]}"
131
- if recent_passages is not None
132
- else ""
133
- ),
134
- "\nCore memory shown below (limited in size, additional information stored in archival / recall memory):",
185
+ "<memory_metadata>",
186
+ f"- The current time is: {get_local_time_fast()}",
187
+ f"- Memory blocks were last modified: {timestamp_str}",
188
+ f"- {previous_message_count} previous messages between you and the user are stored in recall memory (use tools to access them)",
189
+ f"- {archival_memory_size} total memories you created are stored in archival memory (use tools to access them)",
190
+ "</memory_metadata>",
135
191
  ]
136
192
  )
137
193
  return memory_metadata_block
@@ -167,7 +223,6 @@ def compile_system_message(
167
223
  template_format: Literal["f-string", "mustache", "jinja2"] = "f-string",
168
224
  previous_message_count: int = 0,
169
225
  archival_memory_size: int = 0,
170
- recent_passages: Optional[List[PydanticPassage]] = None,
171
226
  ) -> str:
172
227
  """Prepare the final/full system message that will be fed into the LLM API
173
228
 
@@ -192,9 +247,8 @@ def compile_system_message(
192
247
  memory_edit_timestamp=in_context_memory_last_edit,
193
248
  previous_message_count=previous_message_count,
194
249
  archival_memory_size=archival_memory_size,
195
- recent_passages=recent_passages,
196
250
  )
197
- full_memory_string = memory_metadata_string + "\n" + in_context_memory.compile()
251
+ full_memory_string = in_context_memory.compile() + "\n\n" + memory_metadata_string
198
252
 
199
253
  # Add to the variables list to inject
200
254
  variables[IN_CONTEXT_MEMORY_KEYWORD] = full_memory_string
@@ -206,7 +260,7 @@ def compile_system_message(
206
260
  if memory_variable_string not in system_prompt:
207
261
  # In this case, append it to the end to make sure memory is still injected
208
262
  # warnings.warn(f"{IN_CONTEXT_MEMORY_KEYWORD} variable was missing from system prompt, appending instead")
209
- system_prompt += "\n" + memory_variable_string
263
+ system_prompt += "\n\n" + memory_variable_string
210
264
 
211
265
  # render the variables using the built-in templater
212
266
  try:
@@ -437,12 +491,13 @@ def _apply_tag_filter(query, tags: Optional[List[str]], match_all_tags: bool):
437
491
  Returns:
438
492
  The modified query with tag filters applied.
439
493
  """
494
+
440
495
  if tags:
441
496
  if match_all_tags:
442
497
  for tag in tags:
443
498
  query = query.filter(exists().where((AgentsTags.agent_id == AgentModel.id) & (AgentsTags.tag == tag)))
444
- else:
445
- query = query.where(exists().where((AgentsTags.agent_id == AgentModel.id) & (AgentsTags.tag.in_(tags))))
499
+ else:
500
+ query = query.where(exists().where((AgentsTags.agent_id == AgentModel.id) & (AgentsTags.tag.in_(tags))))
446
501
  return query
447
502
 
448
503
 
@@ -60,6 +60,9 @@ def run_subprocess(command: list, env: Optional[Dict[str, str]] = None, fail_msg
60
60
  except subprocess.CalledProcessError as e:
61
61
  logger.error(f"{fail_msg}\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}")
62
62
  raise RuntimeError(f"{fail_msg}: {e.stderr.strip()}") from e
63
+ except Exception as e:
64
+ logger.error(f"{fail_msg}: {e}")
65
+ raise RuntimeError(f"{fail_msg}: {e}")
63
66
 
64
67
 
65
68
  def ensure_pip_is_up_to_date(python_exec: str, env: Optional[Dict[str, str]] = None):
@@ -0,0 +1,100 @@
1
+ import ast
2
+ import base64
3
+ import pickle
4
+ from typing import Any
5
+
6
+ from letta.constants import REQUEST_HEARTBEAT_DESCRIPTION, REQUEST_HEARTBEAT_PARAM, SEND_MESSAGE_TOOL_NAME
7
+ from letta.schemas.agent import AgentState
8
+ from letta.schemas.response_format import ResponseFormatType, ResponseFormatUnion
9
+ from letta.types import JsonDict, JsonValue
10
+
11
+
12
+ def parse_stdout_best_effort(text: str | bytes) -> tuple[Any, AgentState | None]:
13
+ """
14
+ Decode and unpickle the result from the function execution if possible.
15
+ Returns (function_return_value, agent_state).
16
+ """
17
+ if not text:
18
+ return None, None
19
+ if isinstance(text, str):
20
+ text = base64.b64decode(text)
21
+ result = pickle.loads(text)
22
+ agent_state = result["agent_state"]
23
+ return result["results"], agent_state
24
+
25
+
26
+ def parse_function_arguments(source_code: str, tool_name: str):
27
+ """Get arguments of a function from its source code"""
28
+ tree = ast.parse(source_code)
29
+ args = []
30
+ for node in ast.walk(tree):
31
+ if isinstance(node, ast.FunctionDef) and node.name == tool_name:
32
+ for arg in node.args.args:
33
+ args.append(arg.arg)
34
+ return args
35
+
36
+
37
+ def convert_param_to_str_value(param_type: str, raw_value: JsonValue) -> str:
38
+ """
39
+ Convert parameter to Python code representation based on JSON schema type.
40
+ TODO (cliandy): increase sanitization checks here to fail at the right place
41
+ """
42
+
43
+ valid_types = {"string", "integer", "boolean", "number", "array", "object"}
44
+ if param_type not in valid_types:
45
+ raise TypeError(f"Unsupported type: {param_type}, raw_value={raw_value}")
46
+ if param_type == "string":
47
+ # Safely handle python string
48
+ return repr(raw_value)
49
+ if param_type == "integer":
50
+ return str(int(raw_value))
51
+ if param_type == "boolean":
52
+ if isinstance(raw_value, bool):
53
+ return str(raw_value)
54
+ if isinstance(raw_value, int) and raw_value in (0, 1):
55
+ return str(bool(raw_value))
56
+ if isinstance(raw_value, str) and raw_value.strip().lower() in ("true", "false"):
57
+ return raw_value.strip().lower().capitalize()
58
+ raise ValueError(f"Invalid boolean value: {raw_value}")
59
+ if param_type == "array":
60
+ pass # need more testing here
61
+ # if isinstance(raw_value, str):
62
+ # if raw_value.strip()[0] != "[" or raw_value.strip()[-1] != "]":
63
+ # raise ValueError(f'Invalid array value: "{raw_value}"')
64
+ # return raw_value.strip()
65
+ return str(raw_value)
66
+
67
+
68
+ def runtime_override_tool_json_schema(
69
+ tool_list: list[JsonDict],
70
+ response_format: ResponseFormatUnion | None,
71
+ request_heartbeat: bool = True,
72
+ ) -> list[JsonDict]:
73
+ """Override the tool JSON schemas at runtime if certain conditions are met.
74
+
75
+ Cases:
76
+ 1. We will inject `send_message` tool calls with `response_format` if provided
77
+ 2. Tools will have an additional `request_heartbeat` parameter added.
78
+ """
79
+ for tool_json in tool_list:
80
+ if tool_json["name"] == SEND_MESSAGE_TOOL_NAME and response_format and response_format.type != ResponseFormatType.text:
81
+ if response_format.type == ResponseFormatType.json_schema:
82
+ tool_json["parameters"]["properties"]["message"] = response_format.json_schema["schema"]
83
+ if response_format.type == ResponseFormatType.json_object:
84
+ tool_json["parameters"]["properties"]["message"] = {
85
+ "type": "object",
86
+ "description": "Message contents. All unicode (including emojis) are supported.",
87
+ "additionalProperties": True,
88
+ "properties": {},
89
+ }
90
+ if request_heartbeat:
91
+ # TODO (cliandy): see support for tool control loop parameters
92
+ if tool_json["name"] != SEND_MESSAGE_TOOL_NAME:
93
+ tool_json["parameters"]["properties"][REQUEST_HEARTBEAT_PARAM] = {
94
+ "type": "boolean",
95
+ "description": REQUEST_HEARTBEAT_DESCRIPTION,
96
+ }
97
+ if REQUEST_HEARTBEAT_PARAM not in tool_json["parameters"]["required"]:
98
+ tool_json["parameters"]["required"].append(REQUEST_HEARTBEAT_PARAM)
99
+
100
+ return tool_list