letta-nightly 0.8.5.dev20250625104328__py3-none-any.whl → 0.8.6.dev20250625222533__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 (78) hide show
  1. letta/agent.py +16 -12
  2. letta/agents/base_agent.py +4 -1
  3. letta/agents/helpers.py +35 -3
  4. letta/agents/letta_agent.py +132 -106
  5. letta/agents/letta_agent_batch.py +4 -3
  6. letta/agents/voice_agent.py +12 -2
  7. letta/agents/voice_sleeptime_agent.py +12 -2
  8. letta/constants.py +24 -3
  9. letta/data_sources/redis_client.py +6 -0
  10. letta/errors.py +5 -0
  11. letta/functions/function_sets/files.py +10 -3
  12. letta/functions/function_sets/multi_agent.py +0 -32
  13. letta/groups/sleeptime_multi_agent_v2.py +6 -0
  14. letta/helpers/converters.py +4 -1
  15. letta/helpers/datetime_helpers.py +16 -23
  16. letta/helpers/message_helper.py +5 -2
  17. letta/helpers/tool_rule_solver.py +29 -2
  18. letta/interfaces/openai_streaming_interface.py +9 -2
  19. letta/llm_api/anthropic.py +11 -1
  20. letta/llm_api/anthropic_client.py +14 -3
  21. letta/llm_api/aws_bedrock.py +29 -15
  22. letta/llm_api/bedrock_client.py +74 -0
  23. letta/llm_api/google_ai_client.py +7 -3
  24. letta/llm_api/google_vertex_client.py +18 -4
  25. letta/llm_api/llm_client.py +7 -0
  26. letta/llm_api/openai_client.py +13 -0
  27. letta/orm/agent.py +5 -0
  28. letta/orm/block_history.py +1 -1
  29. letta/orm/enums.py +6 -25
  30. letta/orm/job.py +1 -2
  31. letta/orm/llm_batch_items.py +1 -1
  32. letta/orm/mcp_server.py +1 -1
  33. letta/orm/passage.py +7 -1
  34. letta/orm/sqlalchemy_base.py +7 -5
  35. letta/orm/tool.py +2 -1
  36. letta/schemas/agent.py +34 -10
  37. letta/schemas/enums.py +42 -1
  38. letta/schemas/job.py +6 -3
  39. letta/schemas/letta_request.py +4 -0
  40. letta/schemas/llm_batch_job.py +7 -2
  41. letta/schemas/memory.py +2 -2
  42. letta/schemas/providers.py +32 -6
  43. letta/schemas/run.py +1 -1
  44. letta/schemas/tool_rule.py +40 -12
  45. letta/serialize_schemas/pydantic_agent_schema.py +9 -2
  46. letta/server/rest_api/app.py +3 -2
  47. letta/server/rest_api/routers/v1/agents.py +25 -22
  48. letta/server/rest_api/routers/v1/runs.py +2 -3
  49. letta/server/rest_api/routers/v1/sources.py +31 -0
  50. letta/server/rest_api/routers/v1/voice.py +1 -0
  51. letta/server/rest_api/utils.py +38 -13
  52. letta/server/server.py +52 -21
  53. letta/services/agent_manager.py +58 -7
  54. letta/services/block_manager.py +1 -1
  55. letta/services/file_processor/chunker/line_chunker.py +2 -1
  56. letta/services/file_processor/file_processor.py +2 -9
  57. letta/services/files_agents_manager.py +177 -37
  58. letta/services/helpers/agent_manager_helper.py +77 -48
  59. letta/services/helpers/tool_parser_helper.py +2 -1
  60. letta/services/job_manager.py +33 -2
  61. letta/services/llm_batch_manager.py +1 -1
  62. letta/services/provider_manager.py +6 -4
  63. letta/services/tool_executor/core_tool_executor.py +1 -1
  64. letta/services/tool_executor/files_tool_executor.py +99 -30
  65. letta/services/tool_executor/multi_agent_tool_executor.py +1 -17
  66. letta/services/tool_executor/tool_execution_manager.py +6 -0
  67. letta/services/tool_executor/tool_executor_base.py +3 -0
  68. letta/services/tool_sandbox/base.py +39 -1
  69. letta/services/tool_sandbox/e2b_sandbox.py +7 -0
  70. letta/services/user_manager.py +3 -2
  71. letta/settings.py +8 -14
  72. letta/system.py +17 -17
  73. letta/templates/sandbox_code_file_async.py.j2 +59 -0
  74. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250625222533.dist-info}/METADATA +3 -2
  75. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250625222533.dist-info}/RECORD +78 -76
  76. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250625222533.dist-info}/LICENSE +0 -0
  77. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250625222533.dist-info}/WHEEL +0 -0
  78. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250625222533.dist-info}/entry_points.txt +0 -0
@@ -1,15 +1,15 @@
1
- import datetime
1
+ from datetime import datetime
2
2
  from typing import List, Literal, Optional
3
3
 
4
4
  import numpy as np
5
- from sqlalchemy import Select, and_, asc, desc, func, literal, or_, select, union_all
5
+ from sqlalchemy import Select, and_, asc, desc, func, literal, nulls_last, or_, select, union_all
6
6
  from sqlalchemy.sql.expression import exists
7
7
 
8
8
  from letta import system
9
9
  from letta.constants import IN_CONTEXT_MEMORY_KEYWORD, MAX_EMBEDDING_DIM, STRUCTURED_OUTPUT_MODELS
10
10
  from letta.embeddings import embedding_model
11
11
  from letta.helpers import ToolRulesSolver
12
- from letta.helpers.datetime_helpers import get_local_time, get_local_time_fast
12
+ from letta.helpers.datetime_helpers import format_datetime, get_local_time, get_local_time_fast
13
13
  from letta.orm import AgentPassage, SourcePassage, SourcesAgents
14
14
  from letta.orm.agent import Agent as AgentModel
15
15
  from letta.orm.agents_tags import AgentsTags
@@ -178,18 +178,19 @@ def derive_system_message(agent_type: AgentType, enable_sleeptime: Optional[bool
178
178
 
179
179
  # TODO: This code is kind of wonky and deserves a rewrite
180
180
  def compile_memory_metadata_block(
181
- memory_edit_timestamp: datetime.datetime,
181
+ memory_edit_timestamp: datetime,
182
+ timezone: str,
182
183
  previous_message_count: int = 0,
183
184
  archival_memory_size: int = 0,
184
185
  ) -> str:
185
186
  # Put the timestamp in the local timezone (mimicking get_local_time())
186
- timestamp_str = memory_edit_timestamp.astimezone().strftime("%Y-%m-%d %I:%M:%S %p %Z%z").strip()
187
+ timestamp_str = format_datetime(memory_edit_timestamp, timezone)
187
188
 
188
189
  # Create a metadata block of info so the agent knows about the metadata of out-of-context memories
189
190
  memory_metadata_block = "\n".join(
190
191
  [
191
192
  "<memory_metadata>",
192
- f"- The current time is: {get_local_time_fast()}",
193
+ f"- The current time is: {get_local_time_fast(timezone)}",
193
194
  f"- Memory blocks were last modified: {timestamp_str}",
194
195
  f"- {previous_message_count} previous messages between you and the user are stored in recall memory (use tools to access them)",
195
196
  f"- {archival_memory_size} total memories you created are stored in archival memory (use tools to access them)",
@@ -223,7 +224,8 @@ def safe_format(template: str, variables: dict) -> str:
223
224
  def compile_system_message(
224
225
  system_prompt: str,
225
226
  in_context_memory: Memory,
226
- in_context_memory_last_edit: datetime.datetime, # TODO move this inside of BaseMemory?
227
+ in_context_memory_last_edit: datetime, # TODO move this inside of BaseMemory?
228
+ timezone: str,
227
229
  user_defined_variables: Optional[dict] = None,
228
230
  append_icm_if_missing: bool = True,
229
231
  template_format: Literal["f-string", "mustache", "jinja2"] = "f-string",
@@ -239,10 +241,9 @@ def compile_system_message(
239
241
  - CORE_MEMORY: the in-context memory of the LLM
240
242
  """
241
243
  # Add tool rule constraints if available
244
+ tool_constraint_block = None
242
245
  if tool_rules_solver is not None:
243
246
  tool_constraint_block = tool_rules_solver.compile_tool_rule_prompts()
244
- if tool_constraint_block: # There may not be any depending on if there are tool rules attached
245
- in_context_memory.blocks.append(tool_constraint_block)
246
247
 
247
248
  if user_defined_variables is not None:
248
249
  # TODO eventually support the user defining their own variables to inject
@@ -259,8 +260,9 @@ def compile_system_message(
259
260
  memory_edit_timestamp=in_context_memory_last_edit,
260
261
  previous_message_count=previous_message_count,
261
262
  archival_memory_size=archival_memory_size,
263
+ timezone=timezone,
262
264
  )
263
- full_memory_string = in_context_memory.compile() + "\n\n" + memory_metadata_string
265
+ full_memory_string = in_context_memory.compile(tool_usage_rules=tool_constraint_block) + "\n\n" + memory_metadata_string
264
266
 
265
267
  # Add to the variables list to inject
266
268
  variables[IN_CONTEXT_MEMORY_KEYWORD] = full_memory_string
@@ -292,7 +294,7 @@ def compile_system_message(
292
294
 
293
295
  def initialize_message_sequence(
294
296
  agent_state: AgentState,
295
- memory_edit_timestamp: Optional[datetime.datetime] = None,
297
+ memory_edit_timestamp: Optional[datetime] = None,
296
298
  include_initial_boot_message: bool = True,
297
299
  previous_message_count: int = 0,
298
300
  archival_memory_size: int = 0,
@@ -304,20 +306,21 @@ def initialize_message_sequence(
304
306
  system_prompt=agent_state.system,
305
307
  in_context_memory=agent_state.memory,
306
308
  in_context_memory_last_edit=memory_edit_timestamp,
309
+ timezone=agent_state.timezone,
307
310
  user_defined_variables=None,
308
311
  append_icm_if_missing=True,
309
312
  previous_message_count=previous_message_count,
310
313
  archival_memory_size=archival_memory_size,
311
314
  )
312
- first_user_message = get_login_event() # event letting Letta know the user just logged in
315
+ first_user_message = get_login_event(agent_state.timezone) # event letting Letta know the user just logged in
313
316
 
314
317
  if include_initial_boot_message:
315
318
  if agent_state.agent_type == AgentType.sleeptime_agent:
316
319
  initial_boot_messages = []
317
320
  elif agent_state.llm_config.model is not None and "gpt-3.5" in agent_state.llm_config.model:
318
- initial_boot_messages = get_initial_boot_messages("startup_with_send_message_gpt35")
321
+ initial_boot_messages = get_initial_boot_messages("startup_with_send_message_gpt35", agent_state.timezone)
319
322
  else:
320
- initial_boot_messages = get_initial_boot_messages("startup_with_send_message")
323
+ initial_boot_messages = get_initial_boot_messages("startup_with_send_message", agent_state.timezone)
321
324
  messages = (
322
325
  [
323
326
  {"role": "system", "content": full_system_message},
@@ -338,7 +341,7 @@ def initialize_message_sequence(
338
341
 
339
342
 
340
343
  def package_initial_message_sequence(
341
- agent_id: str, initial_message_sequence: List[MessageCreate], model: str, actor: User
344
+ agent_id: str, initial_message_sequence: List[MessageCreate], model: str, timezone: str, actor: User
342
345
  ) -> List[Message]:
343
346
  # create the agent object
344
347
  init_messages = []
@@ -347,6 +350,7 @@ def package_initial_message_sequence(
347
350
  if message_create.role == MessageRole.user:
348
351
  packed_message = system.package_user_message(
349
352
  user_message=message_create.content,
353
+ timezone=timezone,
350
354
  )
351
355
  init_messages.append(
352
356
  Message(
@@ -361,6 +365,7 @@ def package_initial_message_sequence(
361
365
  elif message_create.role == MessageRole.system:
362
366
  packed_message = system.package_system_message(
363
367
  system_message=message_create.content,
368
+ timezone=timezone,
364
369
  )
365
370
  init_messages.append(
366
371
  Message(
@@ -402,7 +407,7 @@ def package_initial_message_sequence(
402
407
  )
403
408
 
404
409
  # add tool return
405
- function_response = package_function_response(True, "None")
410
+ function_response = package_function_response(True, "None", timezone)
406
411
  init_messages.append(
407
412
  Message(
408
413
  role=MessageRole.tool,
@@ -430,23 +435,47 @@ def check_supports_structured_output(model: str, tool_rules: List[ToolRule]) ->
430
435
  return True
431
436
 
432
437
 
433
- def _cursor_filter(created_at_col, id_col, ref_created_at, ref_id, forward: bool):
438
+ def _cursor_filter(sort_col, id_col, ref_sort_col, ref_id, forward: bool, nulls_last: bool = False):
434
439
  """
435
440
  Returns a SQLAlchemy filter expression for cursor-based pagination.
436
441
 
437
442
  If `forward` is True, returns records after the reference.
438
443
  If `forward` is False, returns records before the reference.
444
+
445
+ Handles NULL values in the sort column properly when nulls_last is True.
439
446
  """
440
- if forward:
441
- return or_(
442
- created_at_col > ref_created_at,
443
- and_(created_at_col == ref_created_at, id_col > ref_id),
444
- )
447
+ if not nulls_last:
448
+ # Simple case: no special NULL handling needed
449
+ if forward:
450
+ return or_(
451
+ sort_col > ref_sort_col,
452
+ and_(sort_col == ref_sort_col, id_col > ref_id),
453
+ )
454
+ else:
455
+ return or_(
456
+ sort_col < ref_sort_col,
457
+ and_(sort_col == ref_sort_col, id_col < ref_id),
458
+ )
459
+
460
+ # Handle nulls_last case
461
+ # TODO: add tests to check if this works for ascending order but nulls are stil last?
462
+ if ref_sort_col is None:
463
+ # Reference cursor is at a NULL value
464
+ if forward:
465
+ # Moving forward (e.g. previous) from NULL: either other NULLs with greater IDs or non-NULLs
466
+ return or_(and_(sort_col.is_(None), id_col > ref_id), sort_col.isnot(None))
467
+ else:
468
+ # Moving backward (e.g. next) from NULL: NULLs with smaller IDs
469
+ return and_(sort_col.is_(None), id_col < ref_id)
445
470
  else:
446
- return or_(
447
- created_at_col < ref_created_at,
448
- and_(created_at_col == ref_created_at, id_col < ref_id),
449
- )
471
+ # Reference cursor is at a non-NULL value
472
+ if forward:
473
+ # Moving forward (e.g. previous) from non-NULL: only greater non-NULL values
474
+ # (NULLs are at the end, so we don't include them when moving forward from non-NULL)
475
+ return and_(sort_col.isnot(None), or_(sort_col > ref_sort_col, and_(sort_col == ref_sort_col, id_col > ref_id)))
476
+ else:
477
+ # Moving backward (e.g. next) from non-NULL: smaller non-NULL values or NULLs
478
+ return or_(sort_col.is_(None), or_(sort_col < ref_sort_col, and_(sort_col == ref_sort_col, id_col < ref_id)))
450
479
 
451
480
 
452
481
  def _apply_pagination(
@@ -455,30 +484,30 @@ def _apply_pagination(
455
484
  # Determine the sort column
456
485
  if sort_by == "last_run_completion":
457
486
  sort_column = AgentModel.last_run_completion
487
+ sort_nulls_last = True # TODO: handle this as a query param eventually
458
488
  else:
459
489
  sort_column = AgentModel.created_at
490
+ sort_nulls_last = False
460
491
 
461
492
  if after:
462
- if sort_by == "last_run_completion":
463
- result = session.execute(select(AgentModel.last_run_completion, AgentModel.id).where(AgentModel.id == after)).first()
464
- else:
465
- result = session.execute(select(AgentModel.created_at, AgentModel.id).where(AgentModel.id == after)).first()
493
+ result = session.execute(select(sort_column, AgentModel.id).where(AgentModel.id == after)).first()
466
494
  if result:
467
495
  after_sort_value, after_id = result
468
- query = query.where(_cursor_filter(sort_column, AgentModel.id, after_sort_value, after_id, forward=ascending))
496
+ query = query.where(
497
+ _cursor_filter(sort_column, AgentModel.id, after_sort_value, after_id, forward=ascending, nulls_last=sort_nulls_last)
498
+ )
469
499
 
470
500
  if before:
471
- if sort_by == "last_run_completion":
472
- result = session.execute(select(AgentModel.last_run_completion, AgentModel.id).where(AgentModel.id == before)).first()
473
- else:
474
- result = session.execute(select(AgentModel.created_at, AgentModel.id).where(AgentModel.id == before)).first()
501
+ result = session.execute(select(sort_column, AgentModel.id).where(AgentModel.id == before)).first()
475
502
  if result:
476
503
  before_sort_value, before_id = result
477
- query = query.where(_cursor_filter(sort_column, AgentModel.id, before_sort_value, before_id, forward=not ascending))
504
+ query = query.where(
505
+ _cursor_filter(sort_column, AgentModel.id, before_sort_value, before_id, forward=not ascending, nulls_last=sort_nulls_last)
506
+ )
478
507
 
479
508
  # Apply ordering
480
509
  order_fn = asc if ascending else desc
481
- query = query.order_by(order_fn(sort_column), order_fn(AgentModel.id))
510
+ query = query.order_by(nulls_last(order_fn(sort_column)) if sort_nulls_last else order_fn(sort_column), order_fn(AgentModel.id))
482
511
  return query
483
512
 
484
513
 
@@ -488,30 +517,30 @@ async def _apply_pagination_async(
488
517
  # Determine the sort column
489
518
  if sort_by == "last_run_completion":
490
519
  sort_column = AgentModel.last_run_completion
520
+ sort_nulls_last = True # TODO: handle this as a query param eventually
491
521
  else:
492
522
  sort_column = AgentModel.created_at
523
+ sort_nulls_last = False
493
524
 
494
525
  if after:
495
- if sort_by == "last_run_completion":
496
- result = (await session.execute(select(AgentModel.last_run_completion, AgentModel.id).where(AgentModel.id == after))).first()
497
- else:
498
- result = (await session.execute(select(AgentModel.created_at, AgentModel.id).where(AgentModel.id == after))).first()
526
+ result = (await session.execute(select(sort_column, AgentModel.id).where(AgentModel.id == after))).first()
499
527
  if result:
500
528
  after_sort_value, after_id = result
501
- query = query.where(_cursor_filter(sort_column, AgentModel.id, after_sort_value, after_id, forward=ascending))
529
+ query = query.where(
530
+ _cursor_filter(sort_column, AgentModel.id, after_sort_value, after_id, forward=ascending, nulls_last=sort_nulls_last)
531
+ )
502
532
 
503
533
  if before:
504
- if sort_by == "last_run_completion":
505
- result = (await session.execute(select(AgentModel.last_run_completion, AgentModel.id).where(AgentModel.id == before))).first()
506
- else:
507
- result = (await session.execute(select(AgentModel.created_at, AgentModel.id).where(AgentModel.id == before))).first()
534
+ result = (await session.execute(select(sort_column, AgentModel.id).where(AgentModel.id == before))).first()
508
535
  if result:
509
536
  before_sort_value, before_id = result
510
- query = query.where(_cursor_filter(sort_column, AgentModel.id, before_sort_value, before_id, forward=not ascending))
537
+ query = query.where(
538
+ _cursor_filter(sort_column, AgentModel.id, before_sort_value, before_id, forward=not ascending, nulls_last=sort_nulls_last)
539
+ )
511
540
 
512
541
  # Apply ordering
513
542
  order_fn = asc if ascending else desc
514
- query = query.order_by(order_fn(sort_column), order_fn(AgentModel.id))
543
+ query = query.order_by(nulls_last(order_fn(sort_column)) if sort_nulls_last else order_fn(sort_column), order_fn(AgentModel.id))
515
544
  return query
516
545
 
517
546
 
@@ -28,7 +28,8 @@ def parse_function_arguments(source_code: str, tool_name: str):
28
28
  tree = ast.parse(source_code)
29
29
  args = []
30
30
  for node in ast.walk(tree):
31
- if isinstance(node, ast.FunctionDef) and node.name == tool_name:
31
+ # Handle both sync and async functions
32
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == tool_name:
32
33
  for arg in node.args.args:
33
34
  args.append(arg.arg)
34
35
  return args
@@ -7,7 +7,6 @@ from sqlalchemy.orm import Session
7
7
 
8
8
  from letta.helpers.datetime_helpers import get_utc_time
9
9
  from letta.log import get_logger
10
- from letta.orm.enums import JobType
11
10
  from letta.orm.errors import NoResultFound
12
11
  from letta.orm.job import Job as JobModel
13
12
  from letta.orm.job_messages import JobMessage
@@ -16,7 +15,7 @@ from letta.orm.sqlalchemy_base import AccessType
16
15
  from letta.orm.step import Step
17
16
  from letta.orm.step import Step as StepModel
18
17
  from letta.otel.tracing import trace_method
19
- from letta.schemas.enums import JobStatus, MessageRole
18
+ from letta.schemas.enums import JobStatus, JobType, MessageRole
20
19
  from letta.schemas.job import BatchJob as PydanticBatchJob
21
20
  from letta.schemas.job import Job as PydanticJob
22
21
  from letta.schemas.job import JobUpdate, LettaRequestConfig
@@ -342,6 +341,33 @@ class JobManager:
342
341
  session.add(job_message)
343
342
  session.commit()
344
343
 
344
+ @enforce_types
345
+ @trace_method
346
+ async def add_messages_to_job_async(self, job_id: str, message_ids: List[str], actor: PydanticUser) -> None:
347
+ """
348
+ Associate a message with a job by creating a JobMessage record.
349
+ Each message can only be associated with one job.
350
+
351
+ Args:
352
+ job_id: The ID of the job
353
+ message_id: The ID of the message to associate
354
+ actor: The user making the request
355
+
356
+ Raises:
357
+ NoResultFound: If the job does not exist or user does not have access
358
+ """
359
+ if not message_ids:
360
+ return
361
+
362
+ async with db_registry.async_session() as session:
363
+ # First verify job exists and user has access
364
+ await self._verify_job_access_async(session, job_id, actor, access=["write"])
365
+
366
+ # Create new JobMessage associations
367
+ job_messages = [JobMessage(job_id=job_id, message_id=message_id) for message_id in message_ids]
368
+ session.add_all(job_messages)
369
+ await session.commit()
370
+
345
371
  @enforce_types
346
372
  @trace_method
347
373
  def get_job_usage(self, job_id: str, actor: PydanticUser) -> LettaUsageStatistics:
@@ -463,14 +489,19 @@ class JobManager:
463
489
  )
464
490
 
465
491
  request_config = self._get_run_request_config(run_id)
492
+ print("request_config", request_config)
466
493
 
467
494
  messages = PydanticMessage.to_letta_messages_from_list(
468
495
  messages=messages,
469
496
  use_assistant_message=request_config["use_assistant_message"],
470
497
  assistant_message_tool_name=request_config["assistant_message_tool_name"],
471
498
  assistant_message_tool_kwarg=request_config["assistant_message_tool_kwarg"],
499
+ reverse=not ascending,
472
500
  )
473
501
 
502
+ if request_config["include_return_message_types"]:
503
+ messages = [msg for msg in messages if msg.message_type in request_config["include_return_message_types"]]
504
+
474
505
  return messages
475
506
 
476
507
  @enforce_types
@@ -10,8 +10,8 @@ from letta.orm import Message as MessageModel
10
10
  from letta.orm.llm_batch_items import LLMBatchItem
11
11
  from letta.orm.llm_batch_job import LLMBatchJob
12
12
  from letta.otel.tracing import trace_method
13
- from letta.schemas.agent import AgentStepState
14
13
  from letta.schemas.enums import AgentStepStatus, JobStatus, ProviderType
14
+ from letta.schemas.llm_batch_job import AgentStepState
15
15
  from letta.schemas.llm_batch_job import LLMBatchItem as PydanticLLMBatchItem
16
16
  from letta.schemas.llm_batch_job import LLMBatchJob as PydanticLLMBatchJob
17
17
  from letta.schemas.llm_config import LLMConfig
@@ -1,4 +1,4 @@
1
- from typing import List, Optional, Union
1
+ from typing import List, Optional, Tuple, Union
2
2
 
3
3
  from letta.orm.provider import Provider as ProviderModel
4
4
  from letta.otel.tracing import trace_method
@@ -196,10 +196,12 @@ class ProviderManager:
196
196
 
197
197
  @enforce_types
198
198
  @trace_method
199
- async def get_bedrock_credentials_async(self, provider_name: Union[str, None], actor: PydanticUser) -> Optional[str]:
199
+ async def get_bedrock_credentials_async(
200
+ self, provider_name: Union[str, None], actor: PydanticUser
201
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
200
202
  providers = await self.list_providers_async(name=provider_name, actor=actor)
201
- access_key = providers[0].api_key if providers else None
202
- secret_key = providers[0].api_secret if providers else None
203
+ access_key = providers[0].access_key if providers else None
204
+ secret_key = providers[0].api_key if providers else None
203
205
  region = providers[0].region if providers else None
204
206
  return access_key, secret_key, region
205
207
 
@@ -143,7 +143,7 @@ class LettaCoreToolExecutor(ToolExecutor):
143
143
 
144
144
  try:
145
145
  # Get results using passage manager
146
- all_results = await AgentManager().list_passages_async(
146
+ all_results = await AgentManager().list_agent_passages_async(
147
147
  actor=actor,
148
148
  agent_id=agent_state.id,
149
149
  query_text=query,
@@ -3,8 +3,8 @@ import re
3
3
  from typing import Any, Dict, List, Optional, Tuple
4
4
 
5
5
  from letta.log import get_logger
6
+ from letta.otel.tracing import trace_method
6
7
  from letta.schemas.agent import AgentState
7
- from letta.schemas.file import FileMetadata
8
8
  from letta.schemas.sandbox_config import SandboxConfig
9
9
  from letta.schemas.tool import Tool
10
10
  from letta.schemas.tool_execution_result import ToolExecutionResult
@@ -14,6 +14,7 @@ from letta.services.block_manager import BlockManager
14
14
  from letta.services.file_manager import FileManager
15
15
  from letta.services.file_processor.chunker.line_chunker import LineChunker
16
16
  from letta.services.files_agents_manager import FileAgentManager
17
+ from letta.services.job_manager import JobManager
17
18
  from letta.services.message_manager import MessageManager
18
19
  from letta.services.passage_manager import PassageManager
19
20
  from letta.services.source_manager import SourceManager
@@ -38,6 +39,7 @@ class LettaFileToolExecutor(ToolExecutor):
38
39
  message_manager: MessageManager,
39
40
  agent_manager: AgentManager,
40
41
  block_manager: BlockManager,
42
+ job_manager: JobManager,
41
43
  passage_manager: PassageManager,
42
44
  actor: User,
43
45
  ):
@@ -45,6 +47,7 @@ class LettaFileToolExecutor(ToolExecutor):
45
47
  message_manager=message_manager,
46
48
  agent_manager=agent_manager,
47
49
  block_manager=block_manager,
50
+ job_manager=job_manager,
48
51
  passage_manager=passage_manager,
49
52
  actor=actor,
50
53
  )
@@ -94,6 +97,7 @@ class LettaFileToolExecutor(ToolExecutor):
94
97
  stderr=[get_friendly_error_msg(function_name=function_name, exception_name=type(e).__name__, exception_message=str(e))],
95
98
  )
96
99
 
100
+ @trace_method
97
101
  async def open_file(self, agent_state: AgentState, file_name: str, view_range: Optional[Tuple[int, int]] = None) -> str:
98
102
  """Stub for open_file tool."""
99
103
  start, end = None, None
@@ -120,14 +124,23 @@ class LettaFileToolExecutor(ToolExecutor):
120
124
  # TODO: Inefficient, maybe we can pre-compute this
121
125
  # TODO: This is also not the best way to split things - would be cool to have "content aware" splitting
122
126
  # TODO: Split code differently from large text blurbs
123
- content_lines = LineChunker().chunk_text(text=file.content, file_metadata=file, start=start, end=end)
127
+ content_lines = LineChunker().chunk_text(file_metadata=file, start=start, end=end)
124
128
  visible_content = "\n".join(content_lines)
125
129
 
126
- await self.files_agents_manager.update_file_agent_by_id(
127
- agent_id=agent_state.id, file_id=file_id, actor=self.actor, is_open=True, visible_content=visible_content
130
+ # Efficiently handle LRU eviction and file opening in a single transaction
131
+ closed_files, was_already_open = await self.files_agents_manager.enforce_max_open_files_and_open(
132
+ agent_id=agent_state.id, file_id=file_id, file_name=file_name, actor=self.actor, visible_content=visible_content
128
133
  )
129
- return f"Successfully opened file {file_name}, lines {start} to {end} are now visible in memory block <{file_name}>"
130
134
 
135
+ success_msg = f"Successfully opened file {file_name}, lines {start} to {end} are now visible in memory block <{file_name}>"
136
+ if closed_files:
137
+ success_msg += (
138
+ f"\nNote: Closed {len(closed_files)} least recently used file(s) due to open file limit: {', '.join(closed_files)}"
139
+ )
140
+
141
+ return success_msg
142
+
143
+ @trace_method
131
144
  async def close_file(self, agent_state: AgentState, file_name: str) -> str:
132
145
  """Stub for close_file tool."""
133
146
  await self.files_agents_manager.update_file_agent_by_name(
@@ -146,32 +159,52 @@ class LettaFileToolExecutor(ToolExecutor):
146
159
  except re.error as e:
147
160
  raise ValueError(f"Invalid regex pattern: {e}")
148
161
 
149
- def _get_context_lines(self, text: str, file_metadata: FileMetadata, match_line_idx: int, total_lines: int) -> List[str]:
150
- """Get context lines around a match using LineChunker."""
151
- start_idx = max(0, match_line_idx - self.MAX_CONTEXT_LINES)
152
- end_idx = min(total_lines, match_line_idx + self.MAX_CONTEXT_LINES + 1)
162
+ def _get_context_lines(
163
+ self,
164
+ formatted_lines: List[str],
165
+ match_line_num: int,
166
+ context_lines: int,
167
+ ) -> List[str]:
168
+ """Get context lines around a match from already-chunked lines.
153
169
 
154
- # Use LineChunker to get formatted lines with numbers
155
- chunker = LineChunker()
156
- context_lines = chunker.chunk_text(text, file_metadata=file_metadata, start=start_idx, end=end_idx, add_metadata=False)
170
+ Args:
171
+ formatted_lines: Already chunked lines from LineChunker (format: "line_num: content")
172
+ match_line_num: The 1-based line number of the match
173
+ context_lines: Number of context lines before and after
174
+ """
175
+ if not formatted_lines or context_lines < 0:
176
+ return []
157
177
 
158
- # Add match indicator
159
- formatted_lines = []
160
- for line in context_lines:
178
+ # Find the index of the matching line in the formatted_lines list
179
+ match_formatted_idx = None
180
+ for i, line in enumerate(formatted_lines):
161
181
  if line and ":" in line:
162
- line_num_str = line.split(":")[0].strip()
163
182
  try:
164
- line_num = int(line_num_str)
165
- prefix = ">" if line_num == match_line_idx + 1 else " "
166
- formatted_lines.append(f"{prefix} {line}")
183
+ line_num = int(line.split(":", 1)[0].strip())
184
+ if line_num == match_line_num:
185
+ match_formatted_idx = i
186
+ break
167
187
  except ValueError:
168
- formatted_lines.append(f" {line}")
169
- else:
170
- formatted_lines.append(f" {line}")
188
+ continue
171
189
 
172
- return formatted_lines
190
+ if match_formatted_idx is None:
191
+ return []
173
192
 
174
- async def grep(self, agent_state: AgentState, pattern: str, include: Optional[str] = None) -> str:
193
+ # Calculate context range with bounds checking
194
+ start_idx = max(0, match_formatted_idx - context_lines)
195
+ end_idx = min(len(formatted_lines), match_formatted_idx + context_lines + 1)
196
+
197
+ # Extract context lines and add match indicator
198
+ context_lines_with_indicator = []
199
+ for i in range(start_idx, end_idx):
200
+ line = formatted_lines[i]
201
+ prefix = ">" if i == match_formatted_idx else " "
202
+ context_lines_with_indicator.append(f"{prefix} {line}")
203
+
204
+ return context_lines_with_indicator
205
+
206
+ @trace_method
207
+ async def grep(self, agent_state: AgentState, pattern: str, include: Optional[str] = None, context_lines: Optional[int] = 3) -> str:
175
208
  """
176
209
  Search for pattern in all attached files and return matches with context.
177
210
 
@@ -179,6 +212,8 @@ class LettaFileToolExecutor(ToolExecutor):
179
212
  agent_state: Current agent state
180
213
  pattern: Regular expression pattern to search for
181
214
  include: Optional pattern to filter filenames to include in the search
215
+ context_lines (Optional[int]): Number of lines of context to show before and after each match.
216
+ Equivalent to `-C` in grep. Defaults to 3.
182
217
 
183
218
  Returns:
184
219
  Formatted string with search results, file names, line numbers, and context
@@ -229,10 +264,11 @@ class LettaFileToolExecutor(ToolExecutor):
229
264
  total_content_size = 0
230
265
  files_processed = 0
231
266
  files_skipped = 0
267
+ files_with_matches = set() # Track files that had matches for LRU policy
232
268
 
233
269
  # Use asyncio timeout to prevent hanging
234
270
  async def _search_files():
235
- nonlocal results, total_matches, total_content_size, files_processed, files_skipped
271
+ nonlocal results, total_matches, total_content_size, files_processed, files_skipped, files_with_matches
236
272
 
237
273
  for file_agent in file_agents:
238
274
  # Load file content
@@ -268,12 +304,27 @@ class LettaFileToolExecutor(ToolExecutor):
268
304
 
269
305
  # Use LineChunker to get all lines with proper formatting
270
306
  chunker = LineChunker()
271
- formatted_lines = chunker.chunk_text(file.content, file_metadata=file)
307
+ formatted_lines = chunker.chunk_text(file_metadata=file)
272
308
 
273
309
  # Remove metadata header
274
310
  if formatted_lines and formatted_lines[0].startswith("[Viewing"):
275
311
  formatted_lines = formatted_lines[1:]
276
312
 
313
+ # Convert 0-based line numbers to 1-based for grep compatibility
314
+ corrected_lines = []
315
+ for line in formatted_lines:
316
+ if line and ":" in line:
317
+ try:
318
+ line_parts = line.split(":", 1)
319
+ line_num = int(line_parts[0].strip())
320
+ line_content = line_parts[1] if len(line_parts) > 1 else ""
321
+ corrected_lines.append(f"{line_num + 1}:{line_content}")
322
+ except (ValueError, IndexError):
323
+ corrected_lines.append(line)
324
+ else:
325
+ corrected_lines.append(line)
326
+ formatted_lines = corrected_lines
327
+
277
328
  # Search for matches in formatted lines
278
329
  for formatted_line in formatted_lines:
279
330
  if total_matches >= self.MAX_TOTAL_MATCHES:
@@ -294,12 +345,13 @@ class LettaFileToolExecutor(ToolExecutor):
294
345
  continue
295
346
 
296
347
  if pattern_regex.search(line_content):
297
- # Get context around the match (convert back to 0-based indexing)
298
- context_lines = self._get_context_lines(file.content, file, line_num - 1, len(file.content.splitlines()))
348
+ # Mark this file as having matches for LRU tracking
349
+ files_with_matches.add(file.file_name)
350
+ context = self._get_context_lines(formatted_lines, match_line_num=line_num, context_lines=context_lines or 0)
299
351
 
300
352
  # Format the match result
301
353
  match_header = f"\n=== {file.file_name}:{line_num} ==="
302
- match_content = "\n".join(context_lines)
354
+ match_content = "\n".join(context)
303
355
  results.append(f"{match_header}\n{match_content}")
304
356
 
305
357
  file_matches += 1
@@ -312,6 +364,10 @@ class LettaFileToolExecutor(ToolExecutor):
312
364
  # Execute with timeout
313
365
  await asyncio.wait_for(_search_files(), timeout=self.GREP_TIMEOUT_SECONDS)
314
366
 
367
+ # Mark access for files that had matches
368
+ if files_with_matches:
369
+ await self.files_agents_manager.mark_access_bulk(agent_id=agent_state.id, file_names=list(files_with_matches), actor=self.actor)
370
+
315
371
  # Format final results
316
372
  if not results or total_matches == 0:
317
373
  summary = f"No matches found for pattern: '{pattern}'"
@@ -337,6 +393,7 @@ class LettaFileToolExecutor(ToolExecutor):
337
393
 
338
394
  return "\n".join(formatted_results)
339
395
 
396
+ @trace_method
340
397
  async def search_files(self, agent_state: AgentState, query: str, limit: int = 10) -> str:
341
398
  """
342
399
  Search for text within attached files using semantic search and return passages with their source filenames.
@@ -360,7 +417,13 @@ class LettaFileToolExecutor(ToolExecutor):
360
417
  self.logger.info(f"Semantic search started for agent {agent_state.id} with query '{query}' (limit: {limit})")
361
418
 
362
419
  # Get semantic search results
363
- passages = await self.agent_manager.list_source_passages_async(actor=self.actor, agent_id=agent_state.id, query_text=query)
420
+ passages = await self.agent_manager.list_source_passages_async(
421
+ actor=self.actor,
422
+ agent_id=agent_state.id,
423
+ query_text=query,
424
+ embed_query=True,
425
+ embedding_config=agent_state.embedding_config,
426
+ )
364
427
 
365
428
  if not passages:
366
429
  return f"No semantic matches found for query: '{query}'"
@@ -401,6 +464,12 @@ class LettaFileToolExecutor(ToolExecutor):
401
464
  passage_content = "\n".join(formatted_lines)
402
465
  results.append(f"{passage_header}\n{passage_content}")
403
466
 
467
+ # Mark access for files that had matches
468
+ if files_with_passages:
469
+ matched_file_names = [name for name in files_with_passages.keys() if name != "Unknown File"]
470
+ if matched_file_names:
471
+ await self.files_agents_manager.mark_access_bulk(agent_id=agent_state.id, file_names=matched_file_names, actor=self.actor)
472
+
404
473
  # Create summary header
405
474
  file_count = len(files_with_passages)
406
475
  summary = f"Found {total_passages} semantic matches in {file_count} file{'s' if file_count != 1 else ''} for query: '{query}'"