letta-nightly 0.8.8.dev20250703104323__py3-none-any.whl → 0.8.8.dev20250703174903__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 (68) hide show
  1. letta/agent.py +1 -0
  2. letta/agents/base_agent.py +8 -2
  3. letta/agents/ephemeral_summary_agent.py +33 -33
  4. letta/agents/letta_agent.py +104 -53
  5. letta/agents/voice_agent.py +2 -1
  6. letta/constants.py +8 -4
  7. letta/functions/function_sets/files.py +22 -7
  8. letta/functions/function_sets/multi_agent.py +34 -0
  9. letta/functions/types.py +1 -1
  10. letta/groups/helpers.py +8 -5
  11. letta/groups/sleeptime_multi_agent_v2.py +20 -15
  12. letta/interface.py +1 -1
  13. letta/interfaces/anthropic_streaming_interface.py +15 -8
  14. letta/interfaces/openai_chat_completions_streaming_interface.py +9 -6
  15. letta/interfaces/openai_streaming_interface.py +17 -11
  16. letta/llm_api/openai_client.py +2 -1
  17. letta/orm/agent.py +1 -0
  18. letta/orm/file.py +8 -2
  19. letta/orm/files_agents.py +36 -11
  20. letta/orm/mcp_server.py +3 -0
  21. letta/orm/source.py +2 -1
  22. letta/orm/step.py +3 -0
  23. letta/prompts/system/memgpt_v2_chat.txt +5 -8
  24. letta/schemas/agent.py +58 -23
  25. letta/schemas/embedding_config.py +3 -2
  26. letta/schemas/enums.py +4 -0
  27. letta/schemas/file.py +1 -0
  28. letta/schemas/letta_stop_reason.py +18 -0
  29. letta/schemas/mcp.py +15 -10
  30. letta/schemas/memory.py +35 -5
  31. letta/schemas/providers.py +11 -0
  32. letta/schemas/step.py +1 -0
  33. letta/schemas/tool.py +2 -1
  34. letta/server/rest_api/routers/v1/agents.py +320 -184
  35. letta/server/rest_api/routers/v1/groups.py +6 -2
  36. letta/server/rest_api/routers/v1/identities.py +6 -2
  37. letta/server/rest_api/routers/v1/jobs.py +49 -1
  38. letta/server/rest_api/routers/v1/sources.py +28 -19
  39. letta/server/rest_api/routers/v1/steps.py +7 -2
  40. letta/server/rest_api/routers/v1/tools.py +40 -9
  41. letta/server/rest_api/streaming_response.py +88 -0
  42. letta/server/server.py +61 -55
  43. letta/services/agent_manager.py +28 -16
  44. letta/services/file_manager.py +58 -9
  45. letta/services/file_processor/chunker/llama_index_chunker.py +2 -0
  46. letta/services/file_processor/embedder/openai_embedder.py +54 -10
  47. letta/services/file_processor/file_processor.py +59 -0
  48. letta/services/file_processor/parser/mistral_parser.py +2 -0
  49. letta/services/files_agents_manager.py +120 -2
  50. letta/services/helpers/agent_manager_helper.py +21 -4
  51. letta/services/job_manager.py +57 -6
  52. letta/services/mcp/base_client.py +1 -0
  53. letta/services/mcp_manager.py +13 -1
  54. letta/services/step_manager.py +14 -5
  55. letta/services/summarizer/summarizer.py +6 -22
  56. letta/services/tool_executor/builtin_tool_executor.py +0 -1
  57. letta/services/tool_executor/files_tool_executor.py +2 -2
  58. letta/services/tool_executor/multi_agent_tool_executor.py +23 -0
  59. letta/services/tool_manager.py +7 -7
  60. letta/settings.py +11 -2
  61. letta/templates/summary_request_text.j2 +19 -0
  62. letta/utils.py +95 -14
  63. {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/METADATA +2 -2
  64. {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/RECORD +68 -67
  65. /letta/{agents/prompts → prompts/system}/summary_system_prompt.txt +0 -0
  66. {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/LICENSE +0 -0
  67. {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/WHEEL +0 -0
  68. {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/entry_points.txt +0 -0
@@ -4,15 +4,19 @@ from typing import List, Optional
4
4
  from sqlalchemy import and_, func, select, update
5
5
 
6
6
  from letta.constants import MAX_FILES_OPEN
7
+ from letta.log import get_logger
7
8
  from letta.orm.errors import NoResultFound
8
9
  from letta.orm.files_agents import FileAgent as FileAgentModel
9
10
  from letta.otel.tracing import trace_method
10
11
  from letta.schemas.block import Block as PydanticBlock
11
12
  from letta.schemas.file import FileAgent as PydanticFileAgent
13
+ from letta.schemas.file import FileMetadata
12
14
  from letta.schemas.user import User as PydanticUser
13
15
  from letta.server.db import db_registry
14
16
  from letta.utils import enforce_types
15
17
 
18
+ logger = get_logger(__name__)
19
+
16
20
 
17
21
  class FileAgentManager:
18
22
  """High-level helpers for CRUD / listing on the `files_agents` join table."""
@@ -165,17 +169,19 @@ class FileAgentManager:
165
169
  self,
166
170
  *,
167
171
  file_names: List[str],
172
+ agent_id: str,
168
173
  actor: PydanticUser,
169
174
  ) -> List[PydanticBlock]:
170
175
  """
171
- Retrieve multiple FileAgent associations by their IDs in a single query.
176
+ Retrieve multiple FileAgent associations by their file names for a specific agent.
172
177
 
173
178
  Args:
174
179
  file_names: List of file names to retrieve
180
+ agent_id: ID of the agent to retrieve file blocks for
175
181
  actor: The user making the request
176
182
 
177
183
  Returns:
178
- List of PydanticFileAgent objects found (may be fewer than requested if some IDs don't exist)
184
+ List of PydanticBlock objects found (may be fewer than requested if some file names don't exist)
179
185
  """
180
186
  if not file_names:
181
187
  return []
@@ -185,6 +191,7 @@ class FileAgentManager:
185
191
  query = select(FileAgentModel).where(
186
192
  and_(
187
193
  FileAgentModel.file_name.in_(file_names),
194
+ FileAgentModel.agent_id == agent_id,
188
195
  FileAgentModel.organization_id == actor.organization_id,
189
196
  )
190
197
  )
@@ -420,6 +427,117 @@ class FileAgentManager:
420
427
 
421
428
  return closed_file_names, file_was_already_open
422
429
 
430
+ @enforce_types
431
+ @trace_method
432
+ async def attach_files_bulk(
433
+ self,
434
+ *,
435
+ agent_id: str,
436
+ files_metadata: list[FileMetadata],
437
+ visible_content_map: Optional[dict[str, str]] = None,
438
+ actor: PydanticUser,
439
+ ) -> list[str]:
440
+ """Atomically attach many files, applying an LRU cap with one commit."""
441
+ if not files_metadata:
442
+ return []
443
+
444
+ # TODO: This is not strictly necessary, as the file_metadata should never be duped
445
+ # TODO: But we have this as a protection, check logs for details
446
+ # dedupe while preserving caller order
447
+ seen: set[str] = set()
448
+ ordered_unique: list[FileMetadata] = []
449
+ for m in files_metadata:
450
+ if m.file_name not in seen:
451
+ ordered_unique.append(m)
452
+ seen.add(m.file_name)
453
+ if (dup_cnt := len(files_metadata) - len(ordered_unique)) > 0:
454
+ logger.warning(
455
+ "attach_files_bulk: removed %d duplicate file(s) for agent %s",
456
+ dup_cnt,
457
+ agent_id,
458
+ )
459
+
460
+ now = datetime.now(timezone.utc)
461
+ vc_for = visible_content_map or {}
462
+
463
+ async with db_registry.async_session() as session:
464
+ # fetch existing assoc rows for requested names
465
+ existing_q = select(FileAgentModel).where(
466
+ FileAgentModel.agent_id == agent_id,
467
+ FileAgentModel.organization_id == actor.organization_id,
468
+ FileAgentModel.file_name.in_(seen),
469
+ )
470
+ existing_rows = (await session.execute(existing_q)).scalars().all()
471
+ existing_by_name = {r.file_name: r for r in existing_rows}
472
+
473
+ # snapshot current OPEN rows (oldest first)
474
+ open_q = (
475
+ select(FileAgentModel)
476
+ .where(
477
+ FileAgentModel.agent_id == agent_id,
478
+ FileAgentModel.organization_id == actor.organization_id,
479
+ FileAgentModel.is_open.is_(True),
480
+ )
481
+ .order_by(FileAgentModel.last_accessed_at.asc())
482
+ )
483
+ currently_open = (await session.execute(open_q)).scalars().all()
484
+
485
+ new_names = [m.file_name for m in ordered_unique]
486
+ new_names_set = set(new_names)
487
+ still_open_names = [r.file_name for r in currently_open if r.file_name not in new_names_set]
488
+
489
+ # decide final open set
490
+ if len(new_names) >= MAX_FILES_OPEN:
491
+ final_open = new_names[:MAX_FILES_OPEN]
492
+ else:
493
+ room_for_old = MAX_FILES_OPEN - len(new_names)
494
+ final_open = new_names + still_open_names[-room_for_old:]
495
+ final_open_set = set(final_open)
496
+
497
+ closed_file_names = [r.file_name for r in currently_open if r.file_name not in final_open_set]
498
+ # Add new files that won't be opened due to MAX_FILES_OPEN limit
499
+ if len(new_names) >= MAX_FILES_OPEN:
500
+ closed_file_names.extend(new_names[MAX_FILES_OPEN:])
501
+ evicted_ids = [r.file_id for r in currently_open if r.file_name in closed_file_names]
502
+
503
+ # upsert requested files
504
+ for meta in ordered_unique:
505
+ is_now_open = meta.file_name in final_open_set
506
+ vc = vc_for.get(meta.file_name, "") if is_now_open else None
507
+
508
+ if row := existing_by_name.get(meta.file_name):
509
+ row.is_open = is_now_open
510
+ row.visible_content = vc
511
+ row.last_accessed_at = now
512
+ session.add(row) # already present, but safe
513
+ else:
514
+ session.add(
515
+ FileAgentModel(
516
+ agent_id=agent_id,
517
+ file_id=meta.id,
518
+ file_name=meta.file_name,
519
+ organization_id=actor.organization_id,
520
+ is_open=is_now_open,
521
+ visible_content=vc,
522
+ last_accessed_at=now,
523
+ )
524
+ )
525
+
526
+ # bulk-close evicted rows
527
+ if evicted_ids:
528
+ await session.execute(
529
+ update(FileAgentModel)
530
+ .where(
531
+ FileAgentModel.agent_id == agent_id,
532
+ FileAgentModel.organization_id == actor.organization_id,
533
+ FileAgentModel.file_id.in_(evicted_ids),
534
+ )
535
+ .values(is_open=False, visible_content=None)
536
+ )
537
+
538
+ await session.commit()
539
+ return closed_file_names
540
+
423
541
  async def _get_association_by_file_id(self, session, agent_id: str, file_id: str, actor: PydanticUser) -> FileAgentModel:
424
542
  q = select(FileAgentModel).where(
425
543
  and_(
@@ -1,3 +1,4 @@
1
+ import os
1
2
  from datetime import datetime
2
3
  from typing import List, Literal, Optional, Set
3
4
 
@@ -10,9 +11,11 @@ from letta.constants import (
10
11
  BASE_MEMORY_TOOLS,
11
12
  BASE_MEMORY_TOOLS_V2,
12
13
  BASE_TOOLS,
13
- DEPRECATED_BASE_TOOLS,
14
+ DEPRECATED_LETTA_TOOLS,
14
15
  IN_CONTEXT_MEMORY_KEYWORD,
16
+ LOCAL_ONLY_MULTI_AGENT_TOOLS,
15
17
  MAX_EMBEDDING_DIM,
18
+ MULTI_AGENT_TOOLS,
16
19
  STRUCTURED_OUTPUT_MODELS,
17
20
  )
18
21
  from letta.embeddings import embedding_model
@@ -248,6 +251,7 @@ def compile_system_message(
248
251
  previous_message_count: int = 0,
249
252
  archival_memory_size: int = 0,
250
253
  tool_rules_solver: Optional[ToolRulesSolver] = None,
254
+ sources: Optional[List] = None,
251
255
  ) -> str:
252
256
  """Prepare the final/full system message that will be fed into the LLM API
253
257
 
@@ -256,6 +260,7 @@ def compile_system_message(
256
260
  The following are reserved variables:
257
261
  - CORE_MEMORY: the in-context memory of the LLM
258
262
  """
263
+
259
264
  # Add tool rule constraints if available
260
265
  tool_constraint_block = None
261
266
  if tool_rules_solver is not None:
@@ -278,13 +283,16 @@ def compile_system_message(
278
283
  archival_memory_size=archival_memory_size,
279
284
  timezone=timezone,
280
285
  )
281
- full_memory_string = in_context_memory.compile(tool_usage_rules=tool_constraint_block) + "\n\n" + memory_metadata_string
286
+
287
+ memory_with_sources = in_context_memory.compile(tool_usage_rules=tool_constraint_block, sources=sources)
288
+ full_memory_string = memory_with_sources + "\n\n" + memory_metadata_string
282
289
 
283
290
  # Add to the variables list to inject
284
291
  variables[IN_CONTEXT_MEMORY_KEYWORD] = full_memory_string
285
292
 
286
293
  if template_format == "f-string":
287
294
  memory_variable_string = "{" + IN_CONTEXT_MEMORY_KEYWORD + "}"
295
+
288
296
  # Catch the special case where the system prompt is unformatted
289
297
  if append_icm_if_missing:
290
298
  if memory_variable_string not in system_prompt:
@@ -327,6 +335,7 @@ def initialize_message_sequence(
327
335
  append_icm_if_missing=True,
328
336
  previous_message_count=previous_message_count,
329
337
  archival_memory_size=archival_memory_size,
338
+ sources=agent_state.sources,
330
339
  )
331
340
  first_user_message = get_login_event(agent_state.timezone) # event letting Letta know the user just logged in
332
341
 
@@ -1050,6 +1059,14 @@ def build_agent_passage_query(
1050
1059
 
1051
1060
  def calculate_base_tools(is_v2: bool) -> Set[str]:
1052
1061
  if is_v2:
1053
- return (set(BASE_TOOLS) - set(DEPRECATED_BASE_TOOLS)) | set(BASE_MEMORY_TOOLS_V2)
1062
+ return (set(BASE_TOOLS) - set(DEPRECATED_LETTA_TOOLS)) | set(BASE_MEMORY_TOOLS_V2)
1063
+ else:
1064
+ return (set(BASE_TOOLS) - set(DEPRECATED_LETTA_TOOLS)) | set(BASE_MEMORY_TOOLS)
1065
+
1066
+
1067
+ def calculate_multi_agent_tools() -> Set[str]:
1068
+ """Calculate multi-agent tools, excluding local-only tools in production environment."""
1069
+ if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION":
1070
+ return set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS)
1054
1071
  else:
1055
- return (set(BASE_TOOLS) - set(DEPRECATED_BASE_TOOLS)) | set(BASE_MEMORY_TOOLS)
1072
+ return set(MULTI_AGENT_TOOLS)
@@ -1,4 +1,4 @@
1
- from functools import reduce
1
+ from functools import partial, reduce
2
2
  from operator import add
3
3
  from typing import List, Literal, Optional, Union
4
4
 
@@ -14,7 +14,7 @@ from letta.orm.message import Message as MessageModel
14
14
  from letta.orm.sqlalchemy_base import AccessType
15
15
  from letta.orm.step import Step
16
16
  from letta.orm.step import Step as StepModel
17
- from letta.otel.tracing import trace_method
17
+ from letta.otel.tracing import log_event, trace_method
18
18
  from letta.schemas.enums import JobStatus, JobType, MessageRole
19
19
  from letta.schemas.job import BatchJob as PydanticBatchJob
20
20
  from letta.schemas.job import Job as PydanticJob
@@ -98,7 +98,6 @@ class JobManager:
98
98
  async with db_registry.async_session() as session:
99
99
  # Fetch the job by ID
100
100
  job = await self._verify_job_access_async(session=session, job_id=job_id, actor=actor, access=["write"])
101
- not_completed_before = not bool(job.completed_at)
102
101
 
103
102
  # Update job attributes with only the fields that were explicitly set
104
103
  update_data = job_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
@@ -110,16 +109,62 @@ class JobManager:
110
109
  value = value.replace(tzinfo=None)
111
110
  setattr(job, key, value)
112
111
 
113
- if job_update.status in {JobStatus.completed, JobStatus.failed} and not_completed_before:
112
+ # If we are updating the job to a terminal state
113
+ if job_update.status in {JobStatus.completed, JobStatus.failed}:
114
+ logger.info(f"Current job completed at: {job.completed_at}")
114
115
  job.completed_at = get_utc_time().replace(tzinfo=None)
115
116
  if job.callback_url:
116
117
  await self._dispatch_callback_async(job)
118
+ else:
119
+ logger.info(f"Job does not contain callback url: {job}")
120
+ else:
121
+ logger.info(f"Job update is not terminal {job_update}")
117
122
 
118
123
  # Save the updated job to the database
119
124
  await job.update_async(db_session=session, actor=actor)
120
125
 
121
126
  return job.to_pydantic()
122
127
 
128
+ @enforce_types
129
+ @trace_method
130
+ async def safe_update_job_status_async(
131
+ self, job_id: str, new_status: JobStatus, actor: PydanticUser, metadata: Optional[dict] = None
132
+ ) -> bool:
133
+ """
134
+ Safely update job status with state transition guards.
135
+ Created -> Pending -> Running --> <Terminal>
136
+
137
+ Returns:
138
+ True if update was successful, False if update was skipped due to invalid transition
139
+ """
140
+ try:
141
+ # Get current job state
142
+ current_job = await self.get_job_by_id_async(job_id=job_id, actor=actor)
143
+
144
+ current_status = current_job.status
145
+ if not any(
146
+ (
147
+ new_status.is_terminal and not current_status.is_terminal,
148
+ current_status == JobStatus.created and new_status != JobStatus.created,
149
+ current_status == JobStatus.pending and new_status == JobStatus.running,
150
+ )
151
+ ):
152
+ logger.warning(f"Invalid job status transition from {current_job.status} to {new_status} for job {job_id}")
153
+ return False
154
+
155
+ job_update_builder = partial(JobUpdate, status=new_status)
156
+ if metadata:
157
+ job_update_builder = partial(job_update_builder, metadata=metadata)
158
+ if new_status.is_terminal:
159
+ job_update_builder = partial(job_update_builder, completed_at=get_utc_time())
160
+
161
+ await self.update_job_by_id_async(job_id=job_id, job_update=job_update_builder(), actor=actor)
162
+ return True
163
+
164
+ except Exception as e:
165
+ logger.error(f"Failed to safely update job status for job {job_id}: {e}")
166
+ return False
167
+
123
168
  @enforce_types
124
169
  @trace_method
125
170
  def get_job_by_id(self, job_id: str, actor: PydanticUser) -> PydanticJob:
@@ -628,6 +673,7 @@ class JobManager:
628
673
  request_config = job.request_config or LettaRequestConfig()
629
674
  return request_config
630
675
 
676
+ @trace_method
631
677
  def _dispatch_callback(self, job: JobModel) -> None:
632
678
  """
633
679
  POST a standard JSON payload to job.callback_url
@@ -643,18 +689,21 @@ class JobManager:
643
689
  try:
644
690
  import httpx
645
691
 
692
+ log_event("POST callback dispatched", payload)
646
693
  resp = httpx.post(job.callback_url, json=payload, timeout=5.0)
694
+ log_event("POST callback finished")
647
695
  job.callback_sent_at = get_utc_time().replace(tzinfo=None)
648
696
  job.callback_status_code = resp.status_code
649
697
 
650
698
  except Exception as e:
651
- error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {str(e)}"
699
+ error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {e!s}"
652
700
  logger.error(error_message)
653
701
  # Record the failed attempt
654
702
  job.callback_sent_at = get_utc_time().replace(tzinfo=None)
655
703
  job.callback_error = error_message
656
704
  # Continue silently - callback failures should not affect job completion
657
705
 
706
+ @trace_method
658
707
  async def _dispatch_callback_async(self, job: JobModel) -> None:
659
708
  """
660
709
  POST a standard JSON payload to job.callback_url and record timestamp + HTTP status asynchronously.
@@ -670,12 +719,14 @@ class JobManager:
670
719
  import httpx
671
720
 
672
721
  async with httpx.AsyncClient() as client:
722
+ log_event("POST callback dispatched", payload)
673
723
  resp = await client.post(job.callback_url, json=payload, timeout=5.0)
724
+ log_event("POST callback finished")
674
725
  # Ensure timestamp is timezone-naive for DB compatibility
675
726
  job.callback_sent_at = get_utc_time().replace(tzinfo=None)
676
727
  job.callback_status_code = resp.status_code
677
728
  except Exception as e:
678
- error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {str(e)}"
729
+ error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {e!s}"
679
730
  logger.error(error_message)
680
731
  # Record the failed attempt
681
732
  job.callback_sent_at = get_utc_time().replace(tzinfo=None)
@@ -77,6 +77,7 @@ class AsyncBaseMCPClient:
77
77
  logger.error("MCPClient has not been initialized")
78
78
  raise RuntimeError("MCPClient has not been initialized")
79
79
 
80
+ # TODO: still hitting some async errors for voice agents, need to fix
80
81
  async def cleanup(self):
81
82
  """Clean up resources - ensure this runs in the same task"""
82
83
  if hasattr(self, "_cleanup_task"):
@@ -2,6 +2,8 @@ import json
2
2
  import os
3
3
  from typing import Any, Dict, List, Optional, Tuple, Union
4
4
 
5
+ from sqlalchemy import null
6
+
5
7
  import letta.constants as constants
6
8
  from letta.functions.mcp_client.types import MCPServerType, MCPTool, SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig
7
9
  from letta.log import get_logger
@@ -156,6 +158,10 @@ class MCPManager:
156
158
  pydantic_mcp_server.organization_id = actor.organization_id
157
159
  mcp_server_data = pydantic_mcp_server.model_dump(to_orm=True)
158
160
 
161
+ # Ensure custom_headers None is stored as SQL NULL, not JSON null
162
+ if mcp_server_data.get("custom_headers") is None:
163
+ mcp_server_data.pop("custom_headers", None)
164
+
159
165
  mcp_server = MCPServerModel(**mcp_server_data)
160
166
  mcp_server = await mcp_server.create_async(session, actor=actor)
161
167
  return mcp_server.to_pydantic()
@@ -168,7 +174,13 @@ class MCPManager:
168
174
  mcp_server = await MCPServerModel.read_async(db_session=session, identifier=mcp_server_id, actor=actor)
169
175
 
170
176
  # Update tool attributes with only the fields that were explicitly set
171
- update_data = mcp_server_update.model_dump(to_orm=True, exclude_none=True)
177
+ update_data = mcp_server_update.model_dump(to_orm=True, exclude_unset=True)
178
+
179
+ # Ensure custom_headers None is stored as SQL NULL, not JSON null
180
+ if update_data.get("custom_headers") is None:
181
+ update_data.pop("custom_headers", None)
182
+ setattr(mcp_server, "custom_headers", null())
183
+
172
184
  for key, value in update_data.items():
173
185
  setattr(mcp_server, key, value)
174
186
 
@@ -42,6 +42,7 @@ class StepManager:
42
42
  trace_ids: Optional[list[str]] = None,
43
43
  feedback: Optional[Literal["positive", "negative"]] = None,
44
44
  has_feedback: Optional[bool] = None,
45
+ project_id: Optional[str] = None,
45
46
  ) -> List[PydanticStep]:
46
47
  """List all jobs with optional pagination and status filter."""
47
48
  async with db_registry.async_session() as session:
@@ -54,6 +55,8 @@ class StepManager:
54
55
  filter_kwargs["trace_id"] = trace_ids
55
56
  if feedback:
56
57
  filter_kwargs["feedback"] = feedback
58
+ if project_id:
59
+ filter_kwargs["project_id"] = project_id
57
60
  steps = await StepModel.list_async(
58
61
  db_session=session,
59
62
  before=before,
@@ -82,6 +85,7 @@ class StepManager:
82
85
  provider_id: Optional[str] = None,
83
86
  job_id: Optional[str] = None,
84
87
  step_id: Optional[str] = None,
88
+ project_id: Optional[str] = None,
85
89
  ) -> PydanticStep:
86
90
  step_data = {
87
91
  "origin": None,
@@ -100,6 +104,7 @@ class StepManager:
100
104
  "tags": [],
101
105
  "tid": None,
102
106
  "trace_id": get_trace_id(), # Get the current trace ID
107
+ "project_id": project_id,
103
108
  }
104
109
  if step_id:
105
110
  step_data["id"] = step_id
@@ -125,6 +130,7 @@ class StepManager:
125
130
  provider_id: Optional[str] = None,
126
131
  job_id: Optional[str] = None,
127
132
  step_id: Optional[str] = None,
133
+ project_id: Optional[str] = None,
128
134
  ) -> PydanticStep:
129
135
  step_data = {
130
136
  "origin": None,
@@ -143,6 +149,7 @@ class StepManager:
143
149
  "tags": [],
144
150
  "tid": None,
145
151
  "trace_id": get_trace_id(), # Get the current trace ID
152
+ "project_id": project_id,
146
153
  }
147
154
  if step_id:
148
155
  step_data["id"] = step_id
@@ -173,7 +180,7 @@ class StepManager:
173
180
 
174
181
  @enforce_types
175
182
  @trace_method
176
- def update_step_transaction_id(self, actor: PydanticUser, step_id: str, transaction_id: str) -> PydanticStep:
183
+ async def update_step_transaction_id(self, actor: PydanticUser, step_id: str, transaction_id: str) -> PydanticStep:
177
184
  """Update the transaction ID for a step.
178
185
 
179
186
  Args:
@@ -187,15 +194,15 @@ class StepManager:
187
194
  Raises:
188
195
  NoResultFound: If the step does not exist
189
196
  """
190
- with db_registry.session() as session:
191
- step = session.get(StepModel, step_id)
197
+ async with db_registry.async_session() as session:
198
+ step = await session.get(StepModel, step_id)
192
199
  if not step:
193
200
  raise NoResultFound(f"Step with id {step_id} does not exist")
194
201
  if step.organization_id != actor.organization_id:
195
202
  raise Exception("Unauthorized")
196
203
 
197
204
  step.tid = transaction_id
198
- session.commit()
205
+ await session.commit()
199
206
  return step.to_pydantic()
200
207
 
201
208
  def _verify_job_access(
@@ -226,8 +233,8 @@ class StepManager:
226
233
  raise NoResultFound(f"Job with id {job_id} does not exist or user does not have access")
227
234
  return job
228
235
 
236
+ @staticmethod
229
237
  async def _verify_job_access_async(
230
- self,
231
238
  session: AsyncSession,
232
239
  job_id: str,
233
240
  actor: PydanticUser,
@@ -280,6 +287,7 @@ class NoopStepManager(StepManager):
280
287
  provider_id: Optional[str] = None,
281
288
  job_id: Optional[str] = None,
282
289
  step_id: Optional[str] = None,
290
+ project_id: Optional[str] = None,
283
291
  ) -> PydanticStep:
284
292
  return
285
293
 
@@ -298,5 +306,6 @@ class NoopStepManager(StepManager):
298
306
  provider_id: Optional[str] = None,
299
307
  job_id: Optional[str] = None,
300
308
  step_id: Optional[str] = None,
309
+ project_id: Optional[str] = None,
301
310
  ) -> PydanticStep:
302
311
  return
@@ -11,6 +11,7 @@ from letta.schemas.enums import MessageRole
11
11
  from letta.schemas.letta_message_content import TextContent
12
12
  from letta.schemas.message import Message, MessageCreate
13
13
  from letta.services.summarizer.enums import SummarizationMode
14
+ from letta.templates.template_helper import render_template
14
15
 
15
16
  logger = get_logger(__name__)
16
17
 
@@ -123,30 +124,13 @@ class Summarizer:
123
124
  formatted_evicted_messages = [f"{i}. {msg}" for (i, msg) in enumerate(formatted_evicted_messages)]
124
125
  formatted_in_context_messages = [f"{i + offset}. {msg}" for (i, msg) in enumerate(formatted_in_context_messages)]
125
126
 
126
- evicted_messages_str = "\n".join(formatted_evicted_messages)
127
- in_context_messages_str = "\n".join(formatted_in_context_messages)
128
- # Base prompt
129
- prompt_header = (
130
- f"You’re a memory-recall helper for an AI that can only keep the last {retain_count} messages. "
131
- "Scan the conversation history, focusing on messages about to drop out of that window, "
132
- "and write crisp notes that capture any important facts or insights about the conversation history so they aren’t lost."
127
+ summary_request_text = render_template(
128
+ "summary_request_text.j2",
129
+ retain_count=retain_count,
130
+ evicted_messages=formatted_evicted_messages,
131
+ in_context_messages=formatted_in_context_messages,
133
132
  )
134
133
 
135
- # Sections
136
- evicted_section = f"\n\n(Older) Evicted Messages:\n{evicted_messages_str}" if evicted_messages_str.strip() else ""
137
- in_context_section = ""
138
-
139
- if retain_count > 0 and in_context_messages_str.strip():
140
- in_context_section = f"\n\n(Newer) In-Context Messages:\n{in_context_messages_str}"
141
- elif retain_count == 0:
142
- prompt_header = (
143
- "You’re a memory-recall helper for an AI that is about to forget all prior messages. "
144
- "Scan the conversation history and write crisp notes that capture any important facts or insights about the conversation history."
145
- )
146
-
147
- # Compose final prompt
148
- summary_request_text = prompt_header + evicted_section + in_context_section
149
-
150
134
  # Fire-and-forget the summarization task
151
135
  self.fire_and_forget(
152
136
  self.summarizer_agent.step([MessageCreate(role=MessageRole.user, content=[TextContent(text=summary_request_text)])])
@@ -327,7 +327,6 @@ class LettaBuiltinToolExecutor(ToolExecutor):
327
327
  messages=[{"role": "system", "content": FIRECRAWL_SEARCH_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}],
328
328
  response_format=DocumentAnalysis,
329
329
  temperature=0.1,
330
- max_tokens=300, # Limit output tokens - only need line numbers
331
330
  )
332
331
 
333
332
  end_time = time.time()
@@ -76,7 +76,7 @@ class LettaFileToolExecutor(ToolExecutor):
76
76
  function_map = {
77
77
  "open_files": self.open_files,
78
78
  "grep_files": self.grep_files,
79
- "search_files": self.search_files,
79
+ "semantic_search_files": self.semantic_search_files,
80
80
  }
81
81
 
82
82
  if function_name not in function_map:
@@ -463,7 +463,7 @@ class LettaFileToolExecutor(ToolExecutor):
463
463
  return "\n".join(formatted_results)
464
464
 
465
465
  @trace_method
466
- async def search_files(self, agent_state: AgentState, query: str, limit: int = 10) -> str:
466
+ async def semantic_search_files(self, agent_state: AgentState, query: str, limit: int = 10) -> str:
467
467
  """
468
468
  Search for text within attached files using semantic search and return passages with their source filenames.
469
469
 
@@ -1,6 +1,8 @@
1
1
  import asyncio
2
+ import os
2
3
  from typing import Any, Dict, List, Optional
3
4
 
5
+ from letta.log import get_logger
4
6
  from letta.schemas.agent import AgentState
5
7
  from letta.schemas.enums import MessageRole
6
8
  from letta.schemas.letta_message import AssistantMessage
@@ -12,6 +14,8 @@ from letta.schemas.tool_execution_result import ToolExecutionResult
12
14
  from letta.schemas.user import User
13
15
  from letta.services.tool_executor.tool_executor_base import ToolExecutor
14
16
 
17
+ logger = get_logger(__name__)
18
+
15
19
 
16
20
  class LettaMultiAgentToolExecutor(ToolExecutor):
17
21
  """Executor for LETTA multi-agent core tools."""
@@ -29,6 +33,7 @@ class LettaMultiAgentToolExecutor(ToolExecutor):
29
33
  assert agent_state is not None, "Agent state is required for multi-agent tools"
30
34
  function_map = {
31
35
  "send_message_to_agent_and_wait_for_reply": self.send_message_to_agent_and_wait_for_reply,
36
+ "send_message_to_agent_async": self.send_message_to_agent_async,
32
37
  "send_message_to_agents_matching_tags": self.send_message_to_agents_matching_tags_async,
33
38
  }
34
39
 
@@ -105,3 +110,21 @@ class LettaMultiAgentToolExecutor(ToolExecutor):
105
110
  "error": str(e),
106
111
  "type": type(e).__name__,
107
112
  }
113
+
114
+ async def send_message_to_agent_async(self, agent_state: AgentState, message: str, other_agent_id: str) -> str:
115
+ if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION":
116
+ raise RuntimeError("This tool is not allowed to be run on Letta Cloud.")
117
+
118
+ # 1) Build the prefixed system‐message
119
+ prefixed = (
120
+ f"[Incoming message from agent with ID '{agent_state.id}' - "
121
+ f"to reply to this message, make sure to use the "
122
+ f"'send_message_to_agent_async' tool, or the agent will not receive your message] "
123
+ f"{message}"
124
+ )
125
+
126
+ task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed))
127
+
128
+ task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None))
129
+
130
+ return "Successfully sent message"