letta-nightly 0.10.0.dev20250806104523__py3-none-any.whl → 0.11.0.dev20250807104511__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 (66) hide show
  1. letta/__init__.py +1 -4
  2. letta/agent.py +1 -2
  3. letta/agents/base_agent.py +4 -7
  4. letta/agents/letta_agent.py +59 -51
  5. letta/agents/letta_agent_batch.py +1 -2
  6. letta/agents/voice_agent.py +1 -2
  7. letta/agents/voice_sleeptime_agent.py +1 -3
  8. letta/constants.py +4 -1
  9. letta/embeddings.py +1 -1
  10. letta/functions/function_sets/base.py +0 -1
  11. letta/functions/mcp_client/types.py +4 -0
  12. letta/groups/supervisor_multi_agent.py +1 -1
  13. letta/interfaces/anthropic_streaming_interface.py +16 -24
  14. letta/interfaces/openai_streaming_interface.py +16 -28
  15. letta/llm_api/llm_api_tools.py +3 -3
  16. letta/local_llm/vllm/api.py +3 -0
  17. letta/orm/__init__.py +3 -1
  18. letta/orm/agent.py +8 -0
  19. letta/orm/archive.py +86 -0
  20. letta/orm/archives_agents.py +27 -0
  21. letta/orm/job.py +5 -1
  22. letta/orm/mixins.py +8 -0
  23. letta/orm/organization.py +7 -8
  24. letta/orm/passage.py +12 -10
  25. letta/orm/sqlite_functions.py +2 -2
  26. letta/orm/tool.py +5 -4
  27. letta/schemas/agent.py +4 -2
  28. letta/schemas/agent_file.py +18 -1
  29. letta/schemas/archive.py +44 -0
  30. letta/schemas/embedding_config.py +2 -16
  31. letta/schemas/enums.py +2 -1
  32. letta/schemas/group.py +28 -3
  33. letta/schemas/job.py +4 -0
  34. letta/schemas/llm_config.py +29 -14
  35. letta/schemas/memory.py +9 -3
  36. letta/schemas/npm_requirement.py +12 -0
  37. letta/schemas/passage.py +3 -3
  38. letta/schemas/providers/letta.py +1 -1
  39. letta/schemas/providers/vllm.py +4 -4
  40. letta/schemas/sandbox_config.py +3 -1
  41. letta/schemas/tool.py +10 -38
  42. letta/schemas/tool_rule.py +2 -2
  43. letta/server/db.py +8 -2
  44. letta/server/rest_api/routers/v1/agents.py +9 -8
  45. letta/server/server.py +6 -40
  46. letta/server/startup.sh +3 -0
  47. letta/services/agent_manager.py +92 -31
  48. letta/services/agent_serialization_manager.py +62 -3
  49. letta/services/archive_manager.py +269 -0
  50. letta/services/helpers/agent_manager_helper.py +111 -37
  51. letta/services/job_manager.py +24 -0
  52. letta/services/passage_manager.py +98 -54
  53. letta/services/tool_executor/core_tool_executor.py +0 -1
  54. letta/services/tool_executor/sandbox_tool_executor.py +2 -2
  55. letta/services/tool_executor/tool_execution_manager.py +1 -1
  56. letta/services/tool_manager.py +70 -26
  57. letta/services/tool_sandbox/base.py +2 -2
  58. letta/services/tool_sandbox/local_sandbox.py +5 -1
  59. letta/templates/template_helper.py +8 -0
  60. {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807104511.dist-info}/METADATA +5 -6
  61. {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807104511.dist-info}/RECORD +64 -61
  62. letta/client/client.py +0 -2207
  63. letta/orm/enums.py +0 -21
  64. {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807104511.dist-info}/LICENSE +0 -0
  65. {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807104511.dist-info}/WHEEL +0 -0
  66. {letta_nightly-0.10.0.dev20250806104523.dist-info → letta_nightly-0.11.0.dev20250807104511.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=[], # TODO: Extract and convert 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(self, schema: AgentFileSchema, actor: User, dry_run: bool = False) -> ImportResult:
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 AgentPassage, SourcePassage
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.compile_async(
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("agent_id"),
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("agent_id"),
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
- AgentPassage.id,
958
- AgentPassage.text,
959
- AgentPassage.embedding_config,
960
- AgentPassage.metadata_,
961
- AgentPassage.embedding,
962
- AgentPassage.created_at,
963
- AgentPassage.updated_at,
964
- AgentPassage.is_deleted,
965
- AgentPassage._created_by_id,
966
- AgentPassage._last_updated_by_id,
967
- AgentPassage.organization_id,
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
- AgentPassage.agent_id,
1039
+ ArchivalPassage.archive_id,
971
1040
  )
972
- .where(AgentPassage.agent_id == agent_id)
973
- .where(AgentPassage.organization_id == actor.organization_id)
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 = select(AgentPassage).where(AgentPassage.agent_id == agent_id, AgentPassage.organization_id == actor.organization_id)
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(AgentPassage.created_at >= start_date)
1283
+ query = query.where(ArchivalPassage.created_at >= start_date)
1210
1284
  if end_date:
1211
- query = query.where(AgentPassage.created_at <= end_date)
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(AgentPassage.embedding.cosine_distance(embedded_text).asc())
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(AgentPassage.embedding, query_embedding_binary).asc(),
1223
- AgentPassage.created_at.asc() if ascending else AgentPassage.created_at.desc(),
1224
- AgentPassage.id.asc(),
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(AgentPassage.text).contains(func.lower(query_text)))
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(AgentPassage.created_at, AgentPassage.id).where(AgentPassage.id == before).subquery()
1308
+ before_subq = select(ArchivalPassage.created_at, ArchivalPassage.id).where(ArchivalPassage.id == before).subquery()
1235
1309
  query = query.where(
1236
1310
  or_(
1237
- AgentPassage.created_at < before_subq.c.created_at,
1311
+ ArchivalPassage.created_at < before_subq.c.created_at,
1238
1312
  and_(
1239
- AgentPassage.created_at == before_subq.c.created_at,
1240
- AgentPassage.id < before_subq.c.id,
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(AgentPassage.created_at, AgentPassage.id).where(AgentPassage.id == after).subquery()
1321
+ after_subq = select(ArchivalPassage.created_at, ArchivalPassage.id).where(ArchivalPassage.id == after).subquery()
1248
1322
  query = query.where(
1249
1323
  or_(
1250
- AgentPassage.created_at > after_subq.c.created_at,
1324
+ ArchivalPassage.created_at > after_subq.c.created_at,
1251
1325
  and_(
1252
- AgentPassage.created_at == after_subq.c.created_at,
1253
- AgentPassage.id > after_subq.c.id,
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(AgentPassage.created_at.asc(), AgentPassage.id.asc())
1335
+ query = query.order_by(ArchivalPassage.created_at.asc(), ArchivalPassage.id.asc())
1262
1336
  else:
1263
- query = query.order_by(AgentPassage.created_at.desc(), AgentPassage.id.asc())
1337
+ query = query.order_by(ArchivalPassage.created_at.desc(), ArchivalPassage.id.asc())
1264
1338
 
1265
1339
  return query
1266
1340
 
@@ -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
  """