letta-nightly 0.10.0.dev20250806104523__py3-none-any.whl → 0.11.0.dev20250807000848__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 +1 -4
- letta/agent.py +1 -2
- letta/agents/base_agent.py +4 -7
- letta/agents/letta_agent.py +59 -51
- letta/agents/letta_agent_batch.py +1 -2
- letta/agents/voice_agent.py +1 -2
- letta/agents/voice_sleeptime_agent.py +1 -3
- letta/constants.py +4 -1
- letta/embeddings.py +1 -1
- letta/functions/function_sets/base.py +0 -1
- letta/functions/mcp_client/types.py +4 -0
- letta/groups/supervisor_multi_agent.py +1 -1
- letta/interfaces/anthropic_streaming_interface.py +16 -24
- letta/interfaces/openai_streaming_interface.py +16 -28
- letta/llm_api/llm_api_tools.py +3 -3
- letta/local_llm/vllm/api.py +3 -0
- letta/orm/__init__.py +3 -1
- letta/orm/agent.py +8 -0
- letta/orm/archive.py +86 -0
- letta/orm/archives_agents.py +27 -0
- letta/orm/job.py +5 -1
- letta/orm/mixins.py +8 -0
- letta/orm/organization.py +7 -8
- letta/orm/passage.py +12 -10
- letta/orm/sqlite_functions.py +2 -2
- letta/orm/tool.py +5 -4
- letta/schemas/agent.py +4 -2
- letta/schemas/agent_file.py +18 -1
- letta/schemas/archive.py +44 -0
- letta/schemas/embedding_config.py +2 -16
- letta/schemas/enums.py +2 -1
- letta/schemas/group.py +28 -3
- letta/schemas/job.py +4 -0
- letta/schemas/llm_config.py +29 -14
- letta/schemas/memory.py +9 -3
- letta/schemas/npm_requirement.py +12 -0
- letta/schemas/passage.py +3 -3
- letta/schemas/providers/letta.py +1 -1
- letta/schemas/providers/vllm.py +4 -4
- letta/schemas/sandbox_config.py +3 -1
- letta/schemas/tool.py +10 -38
- letta/schemas/tool_rule.py +2 -2
- letta/server/db.py +8 -2
- letta/server/rest_api/routers/v1/agents.py +9 -8
- letta/server/server.py +6 -40
- letta/server/startup.sh +3 -0
- letta/services/agent_manager.py +92 -31
- letta/services/agent_serialization_manager.py +62 -3
- letta/services/archive_manager.py +269 -0
- letta/services/helpers/agent_manager_helper.py +111 -37
- letta/services/job_manager.py +24 -0
- letta/services/passage_manager.py +98 -54
- letta/services/tool_executor/core_tool_executor.py +0 -1
- letta/services/tool_executor/sandbox_tool_executor.py +2 -2
- letta/services/tool_executor/tool_execution_manager.py +1 -1
- letta/services/tool_manager.py +70 -26
- letta/services/tool_sandbox/base.py +2 -2
- letta/services/tool_sandbox/local_sandbox.py +5 -1
- letta/templates/template_helper.py +8 -0
- {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807000848.dist-info}/METADATA +5 -6
- {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807000848.dist-info}/RECORD +64 -61
- letta/client/client.py +0 -2207
- letta/orm/enums.py +0 -21
- {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807000848.dist-info}/LICENSE +0 -0
- {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807000848.dist-info}/WHEEL +0 -0
- {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807000848.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,5 @@
|
|
1
1
|
from datetime import datetime, timezone
|
2
|
-
from typing import Dict, List
|
2
|
+
from typing import Any, Dict, List, Optional
|
3
3
|
|
4
4
|
from letta.constants import MCP_TOOL_TAG_NAME_PREFIX
|
5
5
|
from letta.errors import AgentFileExportError, AgentFileImportError
|
@@ -22,6 +22,7 @@ from letta.schemas.agent_file import (
|
|
22
22
|
from letta.schemas.block import Block
|
23
23
|
from letta.schemas.enums import FileProcessingStatus
|
24
24
|
from letta.schemas.file import FileMetadata
|
25
|
+
from letta.schemas.group import Group, GroupCreate
|
25
26
|
from letta.schemas.mcp import MCPServer
|
26
27
|
from letta.schemas.message import Message
|
27
28
|
from letta.schemas.source import Source
|
@@ -230,6 +231,9 @@ class AgentSerializationManager:
|
|
230
231
|
file_agent.source_id = self._map_db_to_file_id(file_agent.source_id, SourceSchema.__id_prefix__)
|
231
232
|
file_agent.agent_id = agent_file_id
|
232
233
|
|
234
|
+
if agent_schema.group_ids:
|
235
|
+
agent_schema.group_ids = [self._map_db_to_file_id(group_id, GroupSchema.__id_prefix__) for group_id in agent_schema.group_ids]
|
236
|
+
|
233
237
|
return agent_schema
|
234
238
|
|
235
239
|
def _convert_tool_to_schema(self, tool) -> ToolSchema:
|
@@ -308,6 +312,24 @@ class AgentSerializationManager:
|
|
308
312
|
logger.error(f"Failed to convert MCP server {mcp_server.id}: {e}")
|
309
313
|
raise
|
310
314
|
|
315
|
+
def _convert_group_to_schema(self, group: Group) -> GroupSchema:
|
316
|
+
"""Convert Group to GroupSchema with ID remapping"""
|
317
|
+
try:
|
318
|
+
group_file_id = self._map_db_to_file_id(group.id, GroupSchema.__id_prefix__, allow_new=False)
|
319
|
+
group_schema = GroupSchema.from_group(group)
|
320
|
+
group_schema.id = group_file_id
|
321
|
+
group_schema.agent_ids = [
|
322
|
+
self._map_db_to_file_id(agent_id, AgentSchema.__id_prefix__, allow_new=False) for agent_id in group_schema.agent_ids
|
323
|
+
]
|
324
|
+
if hasattr(group_schema.manager_config, "manager_agent_id"):
|
325
|
+
group_schema.manager_config.manager_agent_id = self._map_db_to_file_id(
|
326
|
+
group_schema.manager_config.manager_agent_id, AgentSchema.__id_prefix__, allow_new=False
|
327
|
+
)
|
328
|
+
return group_schema
|
329
|
+
except Exception as e:
|
330
|
+
logger.error(f"Failed to convert group {group.id}: {e}")
|
331
|
+
raise
|
332
|
+
|
311
333
|
async def export(self, agent_ids: List[str], actor: User) -> AgentFileSchema:
|
312
334
|
"""
|
313
335
|
Export agents and their related entities to AgentFileSchema format.
|
@@ -332,6 +354,23 @@ class AgentSerializationManager:
|
|
332
354
|
missing_ids = [agent_id for agent_id in agent_ids if agent_id not in found_ids]
|
333
355
|
raise AgentFileExportError(f"The following agent IDs were not found: {missing_ids}")
|
334
356
|
|
357
|
+
groups = []
|
358
|
+
group_agent_ids = []
|
359
|
+
for agent_state in agent_states:
|
360
|
+
if agent_state.multi_agent_group != None:
|
361
|
+
groups.append(agent_state.multi_agent_group)
|
362
|
+
group_agent_ids.extend(agent_state.multi_agent_group.agent_ids)
|
363
|
+
|
364
|
+
group_agent_ids = list(set(group_agent_ids) - set(agent_ids))
|
365
|
+
if group_agent_ids:
|
366
|
+
group_agent_states = await self.agent_manager.get_agents_by_ids_async(agent_ids=group_agent_ids, actor=actor)
|
367
|
+
if len(group_agent_states) != len(group_agent_ids):
|
368
|
+
found_ids = {agent.id for agent in group_agent_states}
|
369
|
+
missing_ids = [agent_id for agent_id in group_agent_ids if agent_id not in found_ids]
|
370
|
+
raise AgentFileExportError(f"The following agent IDs were not found: {missing_ids}")
|
371
|
+
agent_ids.extend(group_agent_ids)
|
372
|
+
agent_states.extend(group_agent_states)
|
373
|
+
|
335
374
|
# cache for file-agent relationships to avoid duplicate queries
|
336
375
|
files_agents_cache = {} # Maps agent_id to list of file_agent relationships
|
337
376
|
|
@@ -359,13 +398,14 @@ class AgentSerializationManager:
|
|
359
398
|
source_schemas = [self._convert_source_to_schema(source) for source in source_set]
|
360
399
|
file_schemas = [self._convert_file_to_schema(file_metadata) for file_metadata in file_set]
|
361
400
|
mcp_server_schemas = [self._convert_mcp_server_to_schema(mcp_server) for mcp_server in mcp_server_set]
|
401
|
+
group_schemas = [self._convert_group_to_schema(group) for group in groups]
|
362
402
|
|
363
403
|
logger.info(f"Exporting {len(agent_ids)} agents to agent file format")
|
364
404
|
|
365
405
|
# Return AgentFileSchema with converted entities
|
366
406
|
return AgentFileSchema(
|
367
407
|
agents=agent_schemas,
|
368
|
-
groups=
|
408
|
+
groups=group_schemas,
|
369
409
|
blocks=block_schemas,
|
370
410
|
files=file_schemas,
|
371
411
|
sources=source_schemas,
|
@@ -379,7 +419,13 @@ class AgentSerializationManager:
|
|
379
419
|
logger.error(f"Failed to export agent file: {e}")
|
380
420
|
raise AgentFileExportError(f"Export failed: {e}") from e
|
381
421
|
|
382
|
-
async def import_file(
|
422
|
+
async def import_file(
|
423
|
+
self,
|
424
|
+
schema: AgentFileSchema,
|
425
|
+
actor: User,
|
426
|
+
dry_run: bool = False,
|
427
|
+
env_vars: Optional[Dict[str, Any]] = None,
|
428
|
+
) -> ImportResult:
|
383
429
|
"""
|
384
430
|
Import AgentFileSchema into the database.
|
385
431
|
|
@@ -546,6 +592,10 @@ class AgentSerializationManager:
|
|
546
592
|
if agent_data.get("block_ids"):
|
547
593
|
agent_data["block_ids"] = [file_to_db_ids[file_id] for file_id in agent_data["block_ids"]]
|
548
594
|
|
595
|
+
if env_vars:
|
596
|
+
for var in agent_data["tool_exec_environment_variables"]:
|
597
|
+
var["value"] = env_vars.get(var["key"], "")
|
598
|
+
|
549
599
|
agent_create = CreateAgent(**agent_data)
|
550
600
|
created_agent = await self.agent_manager.create_agent_async(agent_create, actor, _init_with_no_messages=True)
|
551
601
|
file_to_db_ids[agent_schema.id] = created_agent.id
|
@@ -607,6 +657,15 @@ class AgentSerializationManager:
|
|
607
657
|
)
|
608
658
|
imported_count += len(files_for_agent)
|
609
659
|
|
660
|
+
for group in schema.groups:
|
661
|
+
group_data = group.model_dump(exclude={"id"})
|
662
|
+
group_data["agent_ids"] = [file_to_db_ids[agent_id] for agent_id in group_data["agent_ids"]]
|
663
|
+
if "manager_agent_id" in group_data["manager_config"]:
|
664
|
+
group_data["manager_config"]["manager_agent_id"] = file_to_db_ids[group_data["manager_config"]["manager_agent_id"]]
|
665
|
+
created_group = await self.group_manager.create_group_async(GroupCreate(**group_data), actor)
|
666
|
+
file_to_db_ids[group.id] = created_group.id
|
667
|
+
imported_count += 1
|
668
|
+
|
610
669
|
return ImportResult(
|
611
670
|
success=True,
|
612
671
|
message=f"Import completed successfully. Imported {imported_count} entities.",
|
@@ -0,0 +1,269 @@
|
|
1
|
+
from typing import List, Optional
|
2
|
+
|
3
|
+
from sqlalchemy import select
|
4
|
+
|
5
|
+
from letta.log import get_logger
|
6
|
+
from letta.orm import ArchivalPassage
|
7
|
+
from letta.orm import Archive as ArchiveModel
|
8
|
+
from letta.orm import ArchivesAgents
|
9
|
+
from letta.schemas.archive import Archive as PydanticArchive
|
10
|
+
from letta.schemas.user import User as PydanticUser
|
11
|
+
from letta.server.db import db_registry
|
12
|
+
from letta.utils import enforce_types
|
13
|
+
|
14
|
+
logger = get_logger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class ArchiveManager:
|
18
|
+
"""Manager class to handle business logic related to Archives."""
|
19
|
+
|
20
|
+
@enforce_types
|
21
|
+
def create_archive(
|
22
|
+
self,
|
23
|
+
name: str,
|
24
|
+
description: Optional[str] = None,
|
25
|
+
actor: PydanticUser = None,
|
26
|
+
) -> PydanticArchive:
|
27
|
+
"""Create a new archive."""
|
28
|
+
try:
|
29
|
+
with db_registry.session() as session:
|
30
|
+
archive = ArchiveModel(
|
31
|
+
name=name,
|
32
|
+
description=description,
|
33
|
+
organization_id=actor.organization_id,
|
34
|
+
)
|
35
|
+
archive.create(session, actor=actor)
|
36
|
+
return archive.to_pydantic()
|
37
|
+
except Exception as e:
|
38
|
+
logger.exception(f"Failed to create archive {name}. error={e}")
|
39
|
+
raise
|
40
|
+
|
41
|
+
@enforce_types
|
42
|
+
async def create_archive_async(
|
43
|
+
self,
|
44
|
+
name: str,
|
45
|
+
description: Optional[str] = None,
|
46
|
+
actor: PydanticUser = None,
|
47
|
+
) -> PydanticArchive:
|
48
|
+
"""Create a new archive."""
|
49
|
+
try:
|
50
|
+
async with db_registry.async_session() as session:
|
51
|
+
archive = ArchiveModel(
|
52
|
+
name=name,
|
53
|
+
description=description,
|
54
|
+
organization_id=actor.organization_id,
|
55
|
+
)
|
56
|
+
await archive.create_async(session, actor=actor)
|
57
|
+
return archive.to_pydantic()
|
58
|
+
except Exception as e:
|
59
|
+
logger.exception(f"Failed to create archive {name}. error={e}")
|
60
|
+
raise
|
61
|
+
|
62
|
+
@enforce_types
|
63
|
+
async def get_archive_by_id_async(
|
64
|
+
self,
|
65
|
+
archive_id: str,
|
66
|
+
actor: PydanticUser,
|
67
|
+
) -> PydanticArchive:
|
68
|
+
"""Get an archive by ID."""
|
69
|
+
async with db_registry.async_session() as session:
|
70
|
+
archive = await ArchiveModel.read_async(
|
71
|
+
db_session=session,
|
72
|
+
identifier=archive_id,
|
73
|
+
actor=actor,
|
74
|
+
)
|
75
|
+
return archive.to_pydantic()
|
76
|
+
|
77
|
+
@enforce_types
|
78
|
+
def attach_agent_to_archive(
|
79
|
+
self,
|
80
|
+
agent_id: str,
|
81
|
+
archive_id: str,
|
82
|
+
is_owner: bool,
|
83
|
+
actor: PydanticUser,
|
84
|
+
) -> None:
|
85
|
+
"""Attach an agent to an archive."""
|
86
|
+
with db_registry.session() as session:
|
87
|
+
# Check if already attached
|
88
|
+
existing = session.query(ArchivesAgents).filter_by(agent_id=agent_id, archive_id=archive_id).first()
|
89
|
+
|
90
|
+
if existing:
|
91
|
+
# Update ownership if needed
|
92
|
+
if existing.is_owner != is_owner:
|
93
|
+
existing.is_owner = is_owner
|
94
|
+
session.commit()
|
95
|
+
return
|
96
|
+
|
97
|
+
# Create new relationship
|
98
|
+
archives_agents = ArchivesAgents(
|
99
|
+
agent_id=agent_id,
|
100
|
+
archive_id=archive_id,
|
101
|
+
is_owner=is_owner,
|
102
|
+
)
|
103
|
+
session.add(archives_agents)
|
104
|
+
session.commit()
|
105
|
+
|
106
|
+
@enforce_types
|
107
|
+
async def attach_agent_to_archive_async(
|
108
|
+
self,
|
109
|
+
agent_id: str,
|
110
|
+
archive_id: str,
|
111
|
+
is_owner: bool = False,
|
112
|
+
actor: PydanticUser = None,
|
113
|
+
) -> None:
|
114
|
+
"""Attach an agent to an archive."""
|
115
|
+
async with db_registry.async_session() as session:
|
116
|
+
# Check if relationship already exists
|
117
|
+
existing = await session.execute(
|
118
|
+
select(ArchivesAgents).where(
|
119
|
+
ArchivesAgents.agent_id == agent_id,
|
120
|
+
ArchivesAgents.archive_id == archive_id,
|
121
|
+
)
|
122
|
+
)
|
123
|
+
existing_record = existing.scalar_one_or_none()
|
124
|
+
|
125
|
+
if existing_record:
|
126
|
+
# Update ownership if needed
|
127
|
+
if existing_record.is_owner != is_owner:
|
128
|
+
existing_record.is_owner = is_owner
|
129
|
+
await session.commit()
|
130
|
+
return
|
131
|
+
|
132
|
+
# Create the relationship
|
133
|
+
archives_agents = ArchivesAgents(
|
134
|
+
agent_id=agent_id,
|
135
|
+
archive_id=archive_id,
|
136
|
+
is_owner=is_owner,
|
137
|
+
)
|
138
|
+
session.add(archives_agents)
|
139
|
+
await session.commit()
|
140
|
+
|
141
|
+
@enforce_types
|
142
|
+
async def get_or_create_default_archive_for_agent_async(
|
143
|
+
self,
|
144
|
+
agent_id: str,
|
145
|
+
agent_name: Optional[str] = None,
|
146
|
+
actor: PydanticUser = None,
|
147
|
+
) -> PydanticArchive:
|
148
|
+
"""Get the agent's default archive, creating one if it doesn't exist."""
|
149
|
+
# First check if agent has any archives
|
150
|
+
from letta.services.agent_manager import AgentManager
|
151
|
+
|
152
|
+
agent_manager = AgentManager()
|
153
|
+
|
154
|
+
archive_ids = await agent_manager.get_agent_archive_ids_async(
|
155
|
+
agent_id=agent_id,
|
156
|
+
actor=actor,
|
157
|
+
)
|
158
|
+
|
159
|
+
if archive_ids:
|
160
|
+
# TODO: Remove this check once we support multiple archives per agent
|
161
|
+
if len(archive_ids) > 1:
|
162
|
+
raise ValueError(f"Agent {agent_id} has multiple archives, which is not yet supported")
|
163
|
+
# Get the archive
|
164
|
+
archive = await self.get_archive_by_id_async(
|
165
|
+
archive_id=archive_ids[0],
|
166
|
+
actor=actor,
|
167
|
+
)
|
168
|
+
return archive
|
169
|
+
|
170
|
+
# Create a default archive for this agent
|
171
|
+
archive_name = f"{agent_name or f'Agent {agent_id}'}'s Archive"
|
172
|
+
archive = await self.create_archive_async(
|
173
|
+
name=archive_name,
|
174
|
+
description="Default archive created automatically",
|
175
|
+
actor=actor,
|
176
|
+
)
|
177
|
+
|
178
|
+
# Attach the agent to the archive as owner
|
179
|
+
await self.attach_agent_to_archive_async(
|
180
|
+
agent_id=agent_id,
|
181
|
+
archive_id=archive.id,
|
182
|
+
is_owner=True,
|
183
|
+
actor=actor,
|
184
|
+
)
|
185
|
+
|
186
|
+
return archive
|
187
|
+
|
188
|
+
@enforce_types
|
189
|
+
def get_or_create_default_archive_for_agent(
|
190
|
+
self,
|
191
|
+
agent_id: str,
|
192
|
+
agent_name: Optional[str] = None,
|
193
|
+
actor: PydanticUser = None,
|
194
|
+
) -> PydanticArchive:
|
195
|
+
"""Get the agent's default archive, creating one if it doesn't exist."""
|
196
|
+
with db_registry.session() as session:
|
197
|
+
# First check if agent has any archives
|
198
|
+
query = select(ArchivesAgents.archive_id).where(ArchivesAgents.agent_id == agent_id)
|
199
|
+
result = session.execute(query)
|
200
|
+
archive_ids = [row[0] for row in result.fetchall()]
|
201
|
+
|
202
|
+
if archive_ids:
|
203
|
+
# TODO: Remove this check once we support multiple archives per agent
|
204
|
+
if len(archive_ids) > 1:
|
205
|
+
raise ValueError(f"Agent {agent_id} has multiple archives, which is not yet supported")
|
206
|
+
# Get the archive
|
207
|
+
archive = ArchiveModel.read(db_session=session, identifier=archive_ids[0], actor=actor)
|
208
|
+
return archive.to_pydantic()
|
209
|
+
|
210
|
+
# Create a default archive for this agent
|
211
|
+
archive_name = f"{agent_name or f'Agent {agent_id}'}'s Archive"
|
212
|
+
|
213
|
+
# Create the archive
|
214
|
+
archive_model = ArchiveModel(
|
215
|
+
name=archive_name,
|
216
|
+
description="Default archive created automatically",
|
217
|
+
organization_id=actor.organization_id,
|
218
|
+
)
|
219
|
+
archive_model.create(session, actor=actor)
|
220
|
+
|
221
|
+
# Attach the agent to the archive as owner
|
222
|
+
self.attach_agent_to_archive(
|
223
|
+
agent_id=agent_id,
|
224
|
+
archive_id=archive_model.id,
|
225
|
+
is_owner=True,
|
226
|
+
actor=actor,
|
227
|
+
)
|
228
|
+
|
229
|
+
return archive_model.to_pydantic()
|
230
|
+
|
231
|
+
@enforce_types
|
232
|
+
async def get_agents_for_archive_async(
|
233
|
+
self,
|
234
|
+
archive_id: str,
|
235
|
+
actor: PydanticUser,
|
236
|
+
) -> List[str]:
|
237
|
+
"""Get all agent IDs that have access to an archive."""
|
238
|
+
async with db_registry.async_session() as session:
|
239
|
+
result = await session.execute(select(ArchivesAgents.agent_id).where(ArchivesAgents.archive_id == archive_id))
|
240
|
+
return [row[0] for row in result.fetchall()]
|
241
|
+
|
242
|
+
@enforce_types
|
243
|
+
async def get_agent_from_passage_async(
|
244
|
+
self,
|
245
|
+
passage_id: str,
|
246
|
+
actor: PydanticUser,
|
247
|
+
) -> Optional[str]:
|
248
|
+
"""Get the agent ID that owns a passage (through its archive).
|
249
|
+
|
250
|
+
Returns the first agent found (for backwards compatibility).
|
251
|
+
Returns None if no agent found.
|
252
|
+
"""
|
253
|
+
async with db_registry.async_session() as session:
|
254
|
+
# First get the passage to find its archive_id
|
255
|
+
passage = await ArchivalPassage.read_async(
|
256
|
+
db_session=session,
|
257
|
+
identifier=passage_id,
|
258
|
+
actor=actor,
|
259
|
+
)
|
260
|
+
|
261
|
+
# Then find agents connected to that archive
|
262
|
+
result = await session.execute(select(ArchivesAgents.agent_id).where(ArchivesAgents.archive_id == passage.archive_id))
|
263
|
+
agent_ids = [row[0] for row in result.fetchall()]
|
264
|
+
|
265
|
+
if not agent_ids:
|
266
|
+
return None
|
267
|
+
|
268
|
+
# For now, return the first agent (backwards compatibility)
|
269
|
+
return agent_ids[0]
|
@@ -25,9 +25,10 @@ from letta.helpers import ToolRulesSolver
|
|
25
25
|
from letta.helpers.datetime_helpers import format_datetime, get_local_time, get_local_time_fast
|
26
26
|
from letta.orm.agent import Agent as AgentModel
|
27
27
|
from letta.orm.agents_tags import AgentsTags
|
28
|
+
from letta.orm.archives_agents import ArchivesAgents
|
28
29
|
from letta.orm.errors import NoResultFound
|
29
30
|
from letta.orm.identity import Identity
|
30
|
-
from letta.orm.passage import
|
31
|
+
from letta.orm.passage import ArchivalPassage, SourcePassage
|
31
32
|
from letta.orm.sources_agents import SourcesAgents
|
32
33
|
from letta.orm.sqlite_functions import adapt_array
|
33
34
|
from letta.otel.tracing import trace_method
|
@@ -328,6 +329,74 @@ def compile_system_message(
|
|
328
329
|
return formatted_prompt
|
329
330
|
|
330
331
|
|
332
|
+
@trace_method
|
333
|
+
def get_system_message_from_compiled_memory(
|
334
|
+
system_prompt: str,
|
335
|
+
memory_with_sources: str,
|
336
|
+
in_context_memory_last_edit: datetime, # TODO move this inside of BaseMemory?
|
337
|
+
timezone: str,
|
338
|
+
user_defined_variables: Optional[dict] = None,
|
339
|
+
append_icm_if_missing: bool = True,
|
340
|
+
template_format: Literal["f-string", "mustache", "jinja2"] = "f-string",
|
341
|
+
previous_message_count: int = 0,
|
342
|
+
archival_memory_size: int = 0,
|
343
|
+
) -> str:
|
344
|
+
"""Prepare the final/full system message that will be fed into the LLM API
|
345
|
+
|
346
|
+
The base system message may be templated, in which case we need to render the variables.
|
347
|
+
|
348
|
+
The following are reserved variables:
|
349
|
+
- CORE_MEMORY: the in-context memory of the LLM
|
350
|
+
"""
|
351
|
+
if user_defined_variables is not None:
|
352
|
+
# TODO eventually support the user defining their own variables to inject
|
353
|
+
raise NotImplementedError
|
354
|
+
else:
|
355
|
+
variables = {}
|
356
|
+
|
357
|
+
# Add the protected memory variable
|
358
|
+
if IN_CONTEXT_MEMORY_KEYWORD in variables:
|
359
|
+
raise ValueError(f"Found protected variable '{IN_CONTEXT_MEMORY_KEYWORD}' in user-defined vars: {str(user_defined_variables)}")
|
360
|
+
else:
|
361
|
+
# TODO should this all put into the memory.__repr__ function?
|
362
|
+
memory_metadata_string = compile_memory_metadata_block(
|
363
|
+
memory_edit_timestamp=in_context_memory_last_edit,
|
364
|
+
previous_message_count=previous_message_count,
|
365
|
+
archival_memory_size=archival_memory_size,
|
366
|
+
timezone=timezone,
|
367
|
+
)
|
368
|
+
|
369
|
+
full_memory_string = memory_with_sources + "\n\n" + memory_metadata_string
|
370
|
+
|
371
|
+
# Add to the variables list to inject
|
372
|
+
variables[IN_CONTEXT_MEMORY_KEYWORD] = full_memory_string
|
373
|
+
|
374
|
+
if template_format == "f-string":
|
375
|
+
memory_variable_string = "{" + IN_CONTEXT_MEMORY_KEYWORD + "}"
|
376
|
+
|
377
|
+
# Catch the special case where the system prompt is unformatted
|
378
|
+
if append_icm_if_missing:
|
379
|
+
if memory_variable_string not in system_prompt:
|
380
|
+
# In this case, append it to the end to make sure memory is still injected
|
381
|
+
# warnings.warn(f"{IN_CONTEXT_MEMORY_KEYWORD} variable was missing from system prompt, appending instead")
|
382
|
+
system_prompt += "\n\n" + memory_variable_string
|
383
|
+
|
384
|
+
# render the variables using the built-in templater
|
385
|
+
try:
|
386
|
+
if user_defined_variables:
|
387
|
+
formatted_prompt = safe_format(system_prompt, variables)
|
388
|
+
else:
|
389
|
+
formatted_prompt = system_prompt.replace(memory_variable_string, full_memory_string)
|
390
|
+
except Exception as e:
|
391
|
+
raise ValueError(f"Failed to format system prompt - {str(e)}. System prompt value:\n{system_prompt}")
|
392
|
+
|
393
|
+
else:
|
394
|
+
# TODO support for mustache and jinja2
|
395
|
+
raise NotImplementedError(template_format)
|
396
|
+
|
397
|
+
return formatted_prompt
|
398
|
+
|
399
|
+
|
331
400
|
@trace_method
|
332
401
|
async def compile_system_message_async(
|
333
402
|
system_prompt: str,
|
@@ -374,7 +443,7 @@ async def compile_system_message_async(
|
|
374
443
|
timezone=timezone,
|
375
444
|
)
|
376
445
|
|
377
|
-
memory_with_sources = await in_context_memory.
|
446
|
+
memory_with_sources = await in_context_memory.compile_in_thread_async(
|
378
447
|
tool_usage_rules=tool_constraint_block, sources=sources, max_files_open=max_files_open
|
379
448
|
)
|
380
449
|
full_memory_string = memory_with_sources + "\n\n" + memory_metadata_string
|
@@ -918,7 +987,7 @@ def build_passage_query(
|
|
918
987
|
SourcePassage.organization_id,
|
919
988
|
SourcePassage.file_id,
|
920
989
|
SourcePassage.source_id,
|
921
|
-
literal(None).label("
|
990
|
+
literal(None).label("archive_id"),
|
922
991
|
)
|
923
992
|
.join(SourcesAgents, SourcesAgents.source_id == SourcePassage.source_id)
|
924
993
|
.where(SourcesAgents.agent_id == agent_id)
|
@@ -940,7 +1009,7 @@ def build_passage_query(
|
|
940
1009
|
SourcePassage.organization_id,
|
941
1010
|
SourcePassage.file_id,
|
942
1011
|
SourcePassage.source_id,
|
943
|
-
literal(None).label("
|
1012
|
+
literal(None).label("archive_id"),
|
944
1013
|
).where(SourcePassage.organization_id == actor.organization_id)
|
945
1014
|
|
946
1015
|
if source_id:
|
@@ -954,23 +1023,24 @@ def build_passage_query(
|
|
954
1023
|
agent_passages = (
|
955
1024
|
select(
|
956
1025
|
literal(None).label("file_name"),
|
957
|
-
|
958
|
-
|
959
|
-
|
960
|
-
|
961
|
-
|
962
|
-
|
963
|
-
|
964
|
-
|
965
|
-
|
966
|
-
|
967
|
-
|
1026
|
+
ArchivalPassage.id,
|
1027
|
+
ArchivalPassage.text,
|
1028
|
+
ArchivalPassage.embedding_config,
|
1029
|
+
ArchivalPassage.metadata_,
|
1030
|
+
ArchivalPassage.embedding,
|
1031
|
+
ArchivalPassage.created_at,
|
1032
|
+
ArchivalPassage.updated_at,
|
1033
|
+
ArchivalPassage.is_deleted,
|
1034
|
+
ArchivalPassage._created_by_id,
|
1035
|
+
ArchivalPassage._last_updated_by_id,
|
1036
|
+
ArchivalPassage.organization_id,
|
968
1037
|
literal(None).label("file_id"),
|
969
1038
|
literal(None).label("source_id"),
|
970
|
-
|
1039
|
+
ArchivalPassage.archive_id,
|
971
1040
|
)
|
972
|
-
.
|
973
|
-
.where(
|
1041
|
+
.join(ArchivesAgents, ArchivalPassage.archive_id == ArchivesAgents.archive_id)
|
1042
|
+
.where(ArchivesAgents.agent_id == agent_id)
|
1043
|
+
.where(ArchivalPassage.organization_id == actor.organization_id)
|
974
1044
|
)
|
975
1045
|
|
976
1046
|
# Combine queries
|
@@ -1201,56 +1271,60 @@ def build_agent_passage_query(
|
|
1201
1271
|
embedded_text = np.array(embedded_text)
|
1202
1272
|
embedded_text = np.pad(embedded_text, (0, MAX_EMBEDDING_DIM - embedded_text.shape[0]), mode="constant").tolist()
|
1203
1273
|
|
1204
|
-
# Base query for agent passages
|
1205
|
-
query =
|
1274
|
+
# Base query for agent passages - join through archives_agents
|
1275
|
+
query = (
|
1276
|
+
select(ArchivalPassage)
|
1277
|
+
.join(ArchivesAgents, ArchivalPassage.archive_id == ArchivesAgents.archive_id)
|
1278
|
+
.where(ArchivesAgents.agent_id == agent_id, ArchivalPassage.organization_id == actor.organization_id)
|
1279
|
+
)
|
1206
1280
|
|
1207
1281
|
# Apply filters
|
1208
1282
|
if start_date:
|
1209
|
-
query = query.where(
|
1283
|
+
query = query.where(ArchivalPassage.created_at >= start_date)
|
1210
1284
|
if end_date:
|
1211
|
-
query = query.where(
|
1285
|
+
query = query.where(ArchivalPassage.created_at <= end_date)
|
1212
1286
|
|
1213
1287
|
# Handle text search or vector search
|
1214
1288
|
if embedded_text:
|
1215
1289
|
if settings.database_engine is DatabaseChoice.POSTGRES:
|
1216
1290
|
# PostgreSQL with pgvector
|
1217
|
-
query = query.order_by(
|
1291
|
+
query = query.order_by(ArchivalPassage.embedding.cosine_distance(embedded_text).asc())
|
1218
1292
|
else:
|
1219
1293
|
# SQLite with custom vector type
|
1220
1294
|
query_embedding_binary = adapt_array(embedded_text)
|
1221
1295
|
query = query.order_by(
|
1222
|
-
func.cosine_distance(
|
1223
|
-
|
1224
|
-
|
1296
|
+
func.cosine_distance(ArchivalPassage.embedding, query_embedding_binary).asc(),
|
1297
|
+
ArchivalPassage.created_at.asc() if ascending else ArchivalPassage.created_at.desc(),
|
1298
|
+
ArchivalPassage.id.asc(),
|
1225
1299
|
)
|
1226
1300
|
else:
|
1227
1301
|
if query_text:
|
1228
|
-
query = query.where(func.lower(
|
1302
|
+
query = query.where(func.lower(ArchivalPassage.text).contains(func.lower(query_text)))
|
1229
1303
|
|
1230
1304
|
# Handle pagination
|
1231
1305
|
if before or after:
|
1232
1306
|
if before:
|
1233
1307
|
# Get the reference record
|
1234
|
-
before_subq = select(
|
1308
|
+
before_subq = select(ArchivalPassage.created_at, ArchivalPassage.id).where(ArchivalPassage.id == before).subquery()
|
1235
1309
|
query = query.where(
|
1236
1310
|
or_(
|
1237
|
-
|
1311
|
+
ArchivalPassage.created_at < before_subq.c.created_at,
|
1238
1312
|
and_(
|
1239
|
-
|
1240
|
-
|
1313
|
+
ArchivalPassage.created_at == before_subq.c.created_at,
|
1314
|
+
ArchivalPassage.id < before_subq.c.id,
|
1241
1315
|
),
|
1242
1316
|
)
|
1243
1317
|
)
|
1244
1318
|
|
1245
1319
|
if after:
|
1246
1320
|
# Get the reference record
|
1247
|
-
after_subq = select(
|
1321
|
+
after_subq = select(ArchivalPassage.created_at, ArchivalPassage.id).where(ArchivalPassage.id == after).subquery()
|
1248
1322
|
query = query.where(
|
1249
1323
|
or_(
|
1250
|
-
|
1324
|
+
ArchivalPassage.created_at > after_subq.c.created_at,
|
1251
1325
|
and_(
|
1252
|
-
|
1253
|
-
|
1326
|
+
ArchivalPassage.created_at == after_subq.c.created_at,
|
1327
|
+
ArchivalPassage.id > after_subq.c.id,
|
1254
1328
|
),
|
1255
1329
|
)
|
1256
1330
|
)
|
@@ -1258,9 +1332,9 @@ def build_agent_passage_query(
|
|
1258
1332
|
# Apply ordering if not already ordered by similarity
|
1259
1333
|
if not embed_query:
|
1260
1334
|
if ascending:
|
1261
|
-
query = query.order_by(
|
1335
|
+
query = query.order_by(ArchivalPassage.created_at.asc(), ArchivalPassage.id.asc())
|
1262
1336
|
else:
|
1263
|
-
query = query.order_by(
|
1337
|
+
query = query.order_by(ArchivalPassage.created_at.desc(), ArchivalPassage.id.asc())
|
1264
1338
|
|
1265
1339
|
return query
|
1266
1340
|
|
letta/services/job_manager.py
CHANGED
@@ -806,6 +806,30 @@ class JobManager:
|
|
806
806
|
request_config = job.request_config or LettaRequestConfig()
|
807
807
|
return request_config
|
808
808
|
|
809
|
+
@enforce_types
|
810
|
+
async def record_ttft(self, job_id: str, ttft_ns: int, actor: PydanticUser) -> None:
|
811
|
+
"""Record time to first token for a run"""
|
812
|
+
try:
|
813
|
+
async with db_registry.async_session() as session:
|
814
|
+
job = await self._verify_job_access_async(session=session, job_id=job_id, actor=actor, access=["write"])
|
815
|
+
job.ttft_ns = ttft_ns
|
816
|
+
await job.update_async(db_session=session, actor=actor, no_commit=True, no_refresh=True)
|
817
|
+
await session.commit()
|
818
|
+
except Exception as e:
|
819
|
+
logger.warning(f"Failed to record TTFT for job {job_id}: {e}")
|
820
|
+
|
821
|
+
@enforce_types
|
822
|
+
async def record_response_duration(self, job_id: str, total_duration_ns: int, actor: PydanticUser) -> None:
|
823
|
+
"""Record total response duration for a run"""
|
824
|
+
try:
|
825
|
+
async with db_registry.async_session() as session:
|
826
|
+
job = await self._verify_job_access_async(session=session, job_id=job_id, actor=actor, access=["write"])
|
827
|
+
job.total_duration_ns = total_duration_ns
|
828
|
+
await job.update_async(db_session=session, actor=actor, no_commit=True, no_refresh=True)
|
829
|
+
await session.commit()
|
830
|
+
except Exception as e:
|
831
|
+
logger.warning(f"Failed to record response duration for job {job_id}: {e}")
|
832
|
+
|
809
833
|
@trace_method
|
810
834
|
def _dispatch_callback_sync(self, callback_info: dict) -> dict:
|
811
835
|
"""
|