letta-nightly 0.7.30.dev20250603104343__py3-none-any.whl → 0.8.0.dev20250604201135__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/__init__.py +7 -1
- letta/agent.py +14 -7
- letta/agents/base_agent.py +1 -0
- letta/agents/ephemeral_summary_agent.py +104 -0
- letta/agents/helpers.py +35 -3
- letta/agents/letta_agent.py +492 -176
- letta/agents/letta_agent_batch.py +22 -16
- letta/agents/prompts/summary_system_prompt.txt +62 -0
- letta/agents/voice_agent.py +22 -7
- letta/agents/voice_sleeptime_agent.py +13 -8
- letta/constants.py +33 -1
- letta/data_sources/connectors.py +52 -36
- letta/errors.py +4 -0
- letta/functions/ast_parsers.py +13 -30
- letta/functions/function_sets/base.py +3 -1
- letta/functions/functions.py +2 -0
- letta/functions/mcp_client/base_client.py +151 -97
- letta/functions/mcp_client/sse_client.py +49 -31
- letta/functions/mcp_client/stdio_client.py +107 -106
- letta/functions/schema_generator.py +22 -22
- letta/groups/helpers.py +3 -4
- letta/groups/sleeptime_multi_agent.py +4 -4
- letta/groups/sleeptime_multi_agent_v2.py +22 -0
- letta/helpers/composio_helpers.py +16 -0
- letta/helpers/converters.py +20 -0
- letta/helpers/datetime_helpers.py +1 -6
- letta/helpers/tool_rule_solver.py +2 -1
- letta/interfaces/anthropic_streaming_interface.py +17 -2
- letta/interfaces/openai_chat_completions_streaming_interface.py +1 -0
- letta/interfaces/openai_streaming_interface.py +18 -2
- letta/llm_api/anthropic_client.py +24 -3
- letta/llm_api/google_ai_client.py +0 -15
- letta/llm_api/google_vertex_client.py +6 -5
- letta/llm_api/llm_client_base.py +15 -0
- letta/llm_api/openai.py +2 -2
- letta/llm_api/openai_client.py +60 -8
- letta/orm/__init__.py +2 -0
- letta/orm/agent.py +45 -43
- letta/orm/base.py +0 -2
- letta/orm/block.py +1 -0
- letta/orm/custom_columns.py +13 -0
- letta/orm/enums.py +5 -0
- letta/orm/file.py +3 -1
- letta/orm/files_agents.py +68 -0
- letta/orm/mcp_server.py +48 -0
- letta/orm/message.py +1 -0
- letta/orm/organization.py +11 -2
- letta/orm/passage.py +25 -10
- letta/orm/sandbox_config.py +5 -2
- letta/orm/sqlalchemy_base.py +171 -110
- letta/prompts/system/memgpt_base.txt +6 -1
- letta/prompts/system/memgpt_v2_chat.txt +57 -0
- letta/prompts/system/sleeptime.txt +2 -0
- letta/prompts/system/sleeptime_v2.txt +28 -0
- letta/schemas/agent.py +87 -20
- letta/schemas/block.py +7 -1
- letta/schemas/file.py +57 -0
- letta/schemas/mcp.py +74 -0
- letta/schemas/memory.py +5 -2
- letta/schemas/message.py +9 -0
- letta/schemas/openai/openai.py +0 -6
- letta/schemas/providers.py +33 -4
- letta/schemas/tool.py +26 -21
- letta/schemas/tool_execution_result.py +5 -0
- letta/server/db.py +23 -8
- letta/server/rest_api/app.py +73 -56
- letta/server/rest_api/interface.py +4 -4
- letta/server/rest_api/routers/v1/agents.py +132 -47
- letta/server/rest_api/routers/v1/blocks.py +3 -2
- letta/server/rest_api/routers/v1/embeddings.py +3 -3
- letta/server/rest_api/routers/v1/groups.py +3 -3
- letta/server/rest_api/routers/v1/jobs.py +14 -17
- letta/server/rest_api/routers/v1/organizations.py +10 -10
- letta/server/rest_api/routers/v1/providers.py +12 -10
- letta/server/rest_api/routers/v1/runs.py +3 -3
- letta/server/rest_api/routers/v1/sandbox_configs.py +12 -12
- letta/server/rest_api/routers/v1/sources.py +108 -43
- letta/server/rest_api/routers/v1/steps.py +8 -6
- letta/server/rest_api/routers/v1/tools.py +134 -95
- letta/server/rest_api/utils.py +12 -1
- letta/server/server.py +272 -73
- letta/services/agent_manager.py +246 -313
- letta/services/block_manager.py +30 -9
- letta/services/context_window_calculator/__init__.py +0 -0
- letta/services/context_window_calculator/context_window_calculator.py +150 -0
- letta/services/context_window_calculator/token_counter.py +82 -0
- letta/services/file_processor/__init__.py +0 -0
- letta/services/file_processor/chunker/__init__.py +0 -0
- letta/services/file_processor/chunker/llama_index_chunker.py +29 -0
- letta/services/file_processor/embedder/__init__.py +0 -0
- letta/services/file_processor/embedder/openai_embedder.py +84 -0
- letta/services/file_processor/file_processor.py +123 -0
- letta/services/file_processor/parser/__init__.py +0 -0
- letta/services/file_processor/parser/base_parser.py +9 -0
- letta/services/file_processor/parser/mistral_parser.py +54 -0
- letta/services/file_processor/types.py +0 -0
- letta/services/files_agents_manager.py +184 -0
- letta/services/group_manager.py +118 -0
- letta/services/helpers/agent_manager_helper.py +76 -21
- letta/services/helpers/tool_execution_helper.py +3 -0
- letta/services/helpers/tool_parser_helper.py +100 -0
- letta/services/identity_manager.py +44 -42
- letta/services/job_manager.py +21 -10
- letta/services/mcp/base_client.py +5 -2
- letta/services/mcp/sse_client.py +3 -5
- letta/services/mcp/stdio_client.py +3 -5
- letta/services/mcp_manager.py +281 -0
- letta/services/message_manager.py +40 -26
- letta/services/organization_manager.py +55 -19
- letta/services/passage_manager.py +211 -13
- letta/services/provider_manager.py +48 -2
- letta/services/sandbox_config_manager.py +105 -0
- letta/services/source_manager.py +4 -5
- letta/services/step_manager.py +9 -6
- letta/services/summarizer/summarizer.py +50 -23
- letta/services/telemetry_manager.py +7 -0
- letta/services/tool_executor/tool_execution_manager.py +11 -52
- letta/services/tool_executor/tool_execution_sandbox.py +4 -34
- letta/services/tool_executor/tool_executor.py +107 -105
- letta/services/tool_manager.py +56 -17
- letta/services/tool_sandbox/base.py +39 -92
- letta/services/tool_sandbox/e2b_sandbox.py +16 -11
- letta/services/tool_sandbox/local_sandbox.py +51 -23
- letta/services/user_manager.py +36 -3
- letta/settings.py +10 -3
- letta/templates/__init__.py +0 -0
- letta/templates/sandbox_code_file.py.j2 +47 -0
- letta/templates/template_helper.py +16 -0
- letta/tracing.py +30 -1
- letta/types/__init__.py +7 -0
- letta/utils.py +25 -1
- {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604201135.dist-info}/METADATA +7 -2
- {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604201135.dist-info}/RECORD +136 -110
- {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604201135.dist-info}/LICENSE +0 -0
- {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604201135.dist-info}/WHEEL +0 -0
- {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604201135.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
|
letta/services/group_manager.py
CHANGED
@@ -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,
|
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("
|
150
|
+
system = gpt_system.get_system_text("memgpt_v2_chat")
|
103
151
|
elif agent_type == AgentType.memgpt_agent and enable_sleeptime:
|
104
|
-
|
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
|
-
|
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
|
-
|
127
|
-
f"
|
128
|
-
f"
|
129
|
-
(
|
130
|
-
|
131
|
-
|
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 =
|
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
|
-
|
445
|
-
|
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
|