letta-nightly 0.8.5.dev20250625104328__py3-none-any.whl → 0.8.6.dev20250626104326__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- letta/agent.py +16 -12
- letta/agents/base_agent.py +4 -1
- letta/agents/helpers.py +35 -3
- letta/agents/letta_agent.py +132 -106
- letta/agents/letta_agent_batch.py +4 -3
- letta/agents/voice_agent.py +12 -2
- letta/agents/voice_sleeptime_agent.py +12 -2
- letta/constants.py +24 -3
- letta/data_sources/redis_client.py +6 -0
- letta/errors.py +5 -0
- letta/functions/function_sets/files.py +10 -3
- letta/functions/function_sets/multi_agent.py +0 -32
- letta/groups/sleeptime_multi_agent_v2.py +6 -0
- letta/helpers/converters.py +4 -1
- letta/helpers/datetime_helpers.py +16 -23
- letta/helpers/message_helper.py +5 -2
- letta/helpers/tool_rule_solver.py +29 -2
- letta/interfaces/openai_streaming_interface.py +9 -2
- letta/llm_api/anthropic.py +11 -1
- letta/llm_api/anthropic_client.py +14 -3
- letta/llm_api/aws_bedrock.py +29 -15
- letta/llm_api/bedrock_client.py +74 -0
- letta/llm_api/google_ai_client.py +7 -3
- letta/llm_api/google_vertex_client.py +18 -4
- letta/llm_api/llm_client.py +7 -0
- letta/llm_api/openai_client.py +13 -0
- letta/orm/agent.py +5 -0
- letta/orm/block_history.py +1 -1
- letta/orm/enums.py +6 -25
- letta/orm/job.py +1 -2
- letta/orm/llm_batch_items.py +1 -1
- letta/orm/mcp_server.py +1 -1
- letta/orm/passage.py +7 -1
- letta/orm/sqlalchemy_base.py +7 -5
- letta/orm/tool.py +2 -1
- letta/schemas/agent.py +34 -10
- letta/schemas/enums.py +42 -1
- letta/schemas/job.py +6 -3
- letta/schemas/letta_request.py +4 -0
- letta/schemas/llm_batch_job.py +7 -2
- letta/schemas/memory.py +2 -2
- letta/schemas/providers.py +32 -6
- letta/schemas/run.py +1 -1
- letta/schemas/tool_rule.py +40 -12
- letta/serialize_schemas/pydantic_agent_schema.py +9 -2
- letta/server/rest_api/app.py +3 -2
- letta/server/rest_api/routers/v1/agents.py +25 -22
- letta/server/rest_api/routers/v1/runs.py +2 -3
- letta/server/rest_api/routers/v1/sources.py +31 -0
- letta/server/rest_api/routers/v1/voice.py +1 -0
- letta/server/rest_api/utils.py +38 -13
- letta/server/server.py +52 -21
- letta/services/agent_manager.py +58 -7
- letta/services/block_manager.py +1 -1
- letta/services/file_processor/chunker/line_chunker.py +2 -1
- letta/services/file_processor/file_processor.py +2 -9
- letta/services/files_agents_manager.py +177 -37
- letta/services/helpers/agent_manager_helper.py +77 -48
- letta/services/helpers/tool_parser_helper.py +2 -1
- letta/services/job_manager.py +33 -2
- letta/services/llm_batch_manager.py +1 -1
- letta/services/provider_manager.py +6 -4
- letta/services/tool_executor/core_tool_executor.py +1 -1
- letta/services/tool_executor/files_tool_executor.py +99 -30
- letta/services/tool_executor/multi_agent_tool_executor.py +1 -17
- letta/services/tool_executor/tool_execution_manager.py +6 -0
- letta/services/tool_executor/tool_executor_base.py +3 -0
- letta/services/tool_sandbox/base.py +39 -1
- letta/services/tool_sandbox/e2b_sandbox.py +7 -0
- letta/services/user_manager.py +3 -2
- letta/settings.py +8 -14
- letta/system.py +17 -17
- letta/templates/sandbox_code_file_async.py.j2 +59 -0
- {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/METADATA +3 -2
- {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/RECORD +78 -76
- {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.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
|
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
|
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
|
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
|
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(
|
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
|
441
|
-
|
442
|
-
|
443
|
-
|
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
|
-
|
447
|
-
|
448
|
-
|
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
|
-
|
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(
|
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
|
-
|
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(
|
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
|
-
|
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(
|
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
|
-
|
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(
|
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
|
-
|
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
|
letta/services/job_manager.py
CHANGED
@@ -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(
|
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].
|
202
|
-
secret_key = providers[0].
|
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().
|
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(
|
127
|
+
content_lines = LineChunker().chunk_text(file_metadata=file, start=start, end=end)
|
124
128
|
visible_content = "\n".join(content_lines)
|
125
129
|
|
126
|
-
|
127
|
-
|
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(
|
150
|
-
|
151
|
-
|
152
|
-
|
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
|
-
|
155
|
-
|
156
|
-
|
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
|
-
#
|
159
|
-
|
160
|
-
for line in
|
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(
|
165
|
-
|
166
|
-
|
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
|
-
|
169
|
-
else:
|
170
|
-
formatted_lines.append(f" {line}")
|
188
|
+
continue
|
171
189
|
|
172
|
-
|
190
|
+
if match_formatted_idx is None:
|
191
|
+
return []
|
173
192
|
|
174
|
-
|
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(
|
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
|
-
#
|
298
|
-
|
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(
|
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(
|
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}'"
|