letta-nightly 0.11.7.dev20251007104119__py3-none-any.whl → 0.12.0.dev20251009104148__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 (151) hide show
  1. letta/__init__.py +1 -1
  2. letta/adapters/letta_llm_adapter.py +1 -0
  3. letta/adapters/letta_llm_request_adapter.py +0 -1
  4. letta/adapters/letta_llm_stream_adapter.py +7 -2
  5. letta/adapters/simple_llm_request_adapter.py +88 -0
  6. letta/adapters/simple_llm_stream_adapter.py +192 -0
  7. letta/agents/agent_loop.py +6 -0
  8. letta/agents/ephemeral_summary_agent.py +2 -1
  9. letta/agents/helpers.py +142 -6
  10. letta/agents/letta_agent.py +13 -33
  11. letta/agents/letta_agent_batch.py +2 -4
  12. letta/agents/letta_agent_v2.py +87 -77
  13. letta/agents/letta_agent_v3.py +927 -0
  14. letta/agents/voice_agent.py +2 -6
  15. letta/constants.py +8 -4
  16. letta/database_utils.py +161 -0
  17. letta/errors.py +40 -0
  18. letta/functions/function_sets/base.py +84 -4
  19. letta/functions/function_sets/multi_agent.py +0 -3
  20. letta/functions/schema_generator.py +113 -71
  21. letta/groups/dynamic_multi_agent.py +3 -2
  22. letta/groups/helpers.py +1 -2
  23. letta/groups/round_robin_multi_agent.py +3 -2
  24. letta/groups/sleeptime_multi_agent.py +3 -2
  25. letta/groups/sleeptime_multi_agent_v2.py +1 -1
  26. letta/groups/sleeptime_multi_agent_v3.py +17 -17
  27. letta/groups/supervisor_multi_agent.py +84 -80
  28. letta/helpers/converters.py +3 -0
  29. letta/helpers/message_helper.py +4 -0
  30. letta/helpers/tool_rule_solver.py +92 -5
  31. letta/interfaces/anthropic_streaming_interface.py +409 -0
  32. letta/interfaces/gemini_streaming_interface.py +296 -0
  33. letta/interfaces/openai_streaming_interface.py +752 -1
  34. letta/llm_api/anthropic_client.py +127 -16
  35. letta/llm_api/bedrock_client.py +4 -2
  36. letta/llm_api/deepseek_client.py +4 -1
  37. letta/llm_api/google_vertex_client.py +124 -42
  38. letta/llm_api/groq_client.py +4 -1
  39. letta/llm_api/llm_api_tools.py +11 -4
  40. letta/llm_api/llm_client_base.py +6 -2
  41. letta/llm_api/openai.py +32 -2
  42. letta/llm_api/openai_client.py +423 -18
  43. letta/llm_api/xai_client.py +4 -1
  44. letta/main.py +9 -5
  45. letta/memory.py +1 -0
  46. letta/orm/__init__.py +2 -1
  47. letta/orm/agent.py +10 -0
  48. letta/orm/block.py +7 -16
  49. letta/orm/blocks_agents.py +8 -2
  50. letta/orm/files_agents.py +2 -0
  51. letta/orm/job.py +7 -5
  52. letta/orm/mcp_oauth.py +1 -0
  53. letta/orm/message.py +21 -6
  54. letta/orm/organization.py +2 -0
  55. letta/orm/provider.py +6 -2
  56. letta/orm/run.py +71 -0
  57. letta/orm/run_metrics.py +82 -0
  58. letta/orm/sandbox_config.py +7 -1
  59. letta/orm/sqlalchemy_base.py +0 -306
  60. letta/orm/step.py +6 -5
  61. letta/orm/step_metrics.py +5 -5
  62. letta/otel/tracing.py +28 -3
  63. letta/plugins/defaults.py +4 -4
  64. letta/prompts/system_prompts/__init__.py +2 -0
  65. letta/prompts/system_prompts/letta_v1.py +25 -0
  66. letta/schemas/agent.py +3 -2
  67. letta/schemas/agent_file.py +9 -3
  68. letta/schemas/block.py +23 -10
  69. letta/schemas/enums.py +21 -2
  70. letta/schemas/job.py +17 -4
  71. letta/schemas/letta_message_content.py +71 -2
  72. letta/schemas/letta_stop_reason.py +5 -5
  73. letta/schemas/llm_config.py +53 -3
  74. letta/schemas/memory.py +1 -1
  75. letta/schemas/message.py +564 -117
  76. letta/schemas/openai/responses_request.py +64 -0
  77. letta/schemas/providers/__init__.py +2 -0
  78. letta/schemas/providers/anthropic.py +16 -0
  79. letta/schemas/providers/ollama.py +115 -33
  80. letta/schemas/providers/openrouter.py +52 -0
  81. letta/schemas/providers/vllm.py +2 -1
  82. letta/schemas/run.py +48 -42
  83. letta/schemas/run_metrics.py +21 -0
  84. letta/schemas/step.py +2 -2
  85. letta/schemas/step_metrics.py +1 -1
  86. letta/schemas/tool.py +15 -107
  87. letta/schemas/tool_rule.py +88 -5
  88. letta/serialize_schemas/marshmallow_agent.py +1 -0
  89. letta/server/db.py +79 -408
  90. letta/server/rest_api/app.py +61 -10
  91. letta/server/rest_api/dependencies.py +14 -0
  92. letta/server/rest_api/redis_stream_manager.py +19 -8
  93. letta/server/rest_api/routers/v1/agents.py +364 -292
  94. letta/server/rest_api/routers/v1/blocks.py +14 -20
  95. letta/server/rest_api/routers/v1/identities.py +45 -110
  96. letta/server/rest_api/routers/v1/internal_templates.py +21 -0
  97. letta/server/rest_api/routers/v1/jobs.py +23 -6
  98. letta/server/rest_api/routers/v1/messages.py +1 -1
  99. letta/server/rest_api/routers/v1/runs.py +149 -99
  100. letta/server/rest_api/routers/v1/sandbox_configs.py +10 -19
  101. letta/server/rest_api/routers/v1/tools.py +281 -594
  102. letta/server/rest_api/routers/v1/voice.py +1 -1
  103. letta/server/rest_api/streaming_response.py +29 -29
  104. letta/server/rest_api/utils.py +122 -64
  105. letta/server/server.py +160 -887
  106. letta/services/agent_manager.py +236 -919
  107. letta/services/agent_serialization_manager.py +16 -0
  108. letta/services/archive_manager.py +0 -100
  109. letta/services/block_manager.py +211 -168
  110. letta/services/context_window_calculator/token_counter.py +1 -1
  111. letta/services/file_manager.py +1 -1
  112. letta/services/files_agents_manager.py +24 -33
  113. letta/services/group_manager.py +0 -142
  114. letta/services/helpers/agent_manager_helper.py +7 -2
  115. letta/services/helpers/run_manager_helper.py +69 -0
  116. letta/services/job_manager.py +96 -411
  117. letta/services/lettuce/__init__.py +6 -0
  118. letta/services/lettuce/lettuce_client_base.py +86 -0
  119. letta/services/mcp_manager.py +38 -6
  120. letta/services/message_manager.py +165 -362
  121. letta/services/organization_manager.py +0 -36
  122. letta/services/passage_manager.py +0 -345
  123. letta/services/provider_manager.py +0 -80
  124. letta/services/run_manager.py +364 -0
  125. letta/services/sandbox_config_manager.py +0 -234
  126. letta/services/step_manager.py +62 -39
  127. letta/services/summarizer/summarizer.py +9 -7
  128. letta/services/telemetry_manager.py +0 -16
  129. letta/services/tool_executor/builtin_tool_executor.py +35 -0
  130. letta/services/tool_executor/core_tool_executor.py +397 -2
  131. letta/services/tool_executor/files_tool_executor.py +3 -3
  132. letta/services/tool_executor/multi_agent_tool_executor.py +30 -15
  133. letta/services/tool_executor/tool_execution_manager.py +6 -8
  134. letta/services/tool_executor/tool_executor_base.py +3 -3
  135. letta/services/tool_manager.py +85 -339
  136. letta/services/tool_sandbox/base.py +24 -13
  137. letta/services/tool_sandbox/e2b_sandbox.py +16 -1
  138. letta/services/tool_schema_generator.py +123 -0
  139. letta/services/user_manager.py +0 -99
  140. letta/settings.py +20 -4
  141. letta/system.py +5 -1
  142. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/METADATA +3 -5
  143. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/RECORD +146 -135
  144. letta/agents/temporal/activities/__init__.py +0 -4
  145. letta/agents/temporal/activities/example_activity.py +0 -7
  146. letta/agents/temporal/activities/prepare_messages.py +0 -10
  147. letta/agents/temporal/temporal_agent_workflow.py +0 -56
  148. letta/agents/temporal/types.py +0 -25
  149. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/WHEEL +0 -0
  150. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/entry_points.txt +0 -0
  151. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/licenses/LICENSE +0 -0
@@ -3,8 +3,9 @@ from datetime import datetime
3
3
  from typing import Dict, List, Optional
4
4
 
5
5
  from sqlalchemy import and_, delete, func, or_, select
6
- from sqlalchemy.orm import Session
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
7
 
8
+ from letta.errors import LettaInvalidArgumentError
8
9
  from letta.log import get_logger
9
10
  from letta.orm.agent import Agent as AgentModel
10
11
  from letta.orm.block import Block as BlockModel
@@ -23,24 +24,65 @@ from letta.utils import enforce_types
23
24
  logger = get_logger(__name__)
24
25
 
25
26
 
27
+ def validate_block_limit_constraint(update_data: dict, existing_block: BlockModel) -> None:
28
+ """
29
+ Validates that block limit constraints are satisfied when updating a block.
30
+
31
+ Rules:
32
+ - If limit is being updated, it must be >= the length of the value (existing or new)
33
+ - If value is being updated, its length must not exceed the limit (existing or new)
34
+
35
+ Args:
36
+ update_data: Dictionary of fields to update
37
+ existing_block: The current block being updated
38
+
39
+ Raises:
40
+ LettaInvalidArgumentError: If validation fails
41
+ """
42
+ # If limit is being updated, ensure it's >= current value length
43
+ if "limit" in update_data:
44
+ # Get the value that will be used (either from update_data or existing)
45
+ value_to_check = update_data.get("value", existing_block.value)
46
+ limit_to_check = update_data["limit"]
47
+ if value_to_check and limit_to_check < len(value_to_check):
48
+ raise LettaInvalidArgumentError(
49
+ f"Limit ({limit_to_check}) cannot be less than current value length ({len(value_to_check)} characters)",
50
+ argument_name="limit",
51
+ )
52
+ # If value is being updated and there's an existing limit, ensure value doesn't exceed limit
53
+ elif "value" in update_data and existing_block.limit:
54
+ if len(update_data["value"]) > existing_block.limit:
55
+ raise LettaInvalidArgumentError(
56
+ f"Value length ({len(update_data['value'])} characters) exceeds block limit ({existing_block.limit} characters)",
57
+ argument_name="value",
58
+ )
59
+
60
+
61
+ def validate_block_creation(block_data: dict) -> None:
62
+ """
63
+ Validates that block limit constraints are satisfied when creating a block.
64
+
65
+ Rules:
66
+ - If both value and limit are provided, limit must be >= value length
67
+
68
+ Args:
69
+ block_data: Dictionary of block fields for creation
70
+
71
+ Raises:
72
+ LettaInvalidArgumentError: If validation fails
73
+ """
74
+ value = block_data.get("value")
75
+ limit = block_data.get("limit")
76
+
77
+ if value and limit and len(value) > limit:
78
+ raise LettaInvalidArgumentError(
79
+ f"Block limit ({limit}) must be greater than or equal to value length ({len(value)} characters)", argument_name="limit"
80
+ )
81
+
82
+
26
83
  class BlockManager:
27
84
  """Manager class to handle business logic related to Blocks."""
28
85
 
29
- @enforce_types
30
- @trace_method
31
- def create_or_update_block(self, block: PydanticBlock, actor: PydanticUser) -> PydanticBlock:
32
- """Create a new block based on the Block schema."""
33
- db_block = self.get_block_by_id(block.id, actor)
34
- if db_block:
35
- update_data = BlockUpdate(**block.model_dump(to_orm=True, exclude_none=True))
36
- self.update_block(block.id, update_data, actor)
37
- else:
38
- with db_registry.session() as session:
39
- data = block.model_dump(to_orm=True, exclude_none=True)
40
- block = BlockModel(**data, organization_id=actor.organization_id)
41
- block.create(session, actor=actor)
42
- return block.to_pydantic()
43
-
44
86
  @enforce_types
45
87
  @trace_method
46
88
  async def create_or_update_block_async(self, block: PydanticBlock, actor: PydanticUser) -> PydanticBlock:
@@ -52,36 +94,14 @@ class BlockManager:
52
94
  else:
53
95
  async with db_registry.async_session() as session:
54
96
  data = block.model_dump(to_orm=True, exclude_none=True)
97
+ # Validate block creation constraints
98
+ validate_block_creation(data)
55
99
  block = BlockModel(**data, organization_id=actor.organization_id)
56
100
  await block.create_async(session, actor=actor, no_commit=True, no_refresh=True)
57
101
  pydantic_block = block.to_pydantic()
58
102
  await session.commit()
59
103
  return pydantic_block
60
104
 
61
- @enforce_types
62
- @trace_method
63
- def batch_create_blocks(self, blocks: List[PydanticBlock], actor: PydanticUser) -> List[PydanticBlock]:
64
- """
65
- Batch-create multiple Blocks in one transaction for better performance.
66
- Args:
67
- blocks: List of PydanticBlock schemas to create
68
- actor: The user performing the operation
69
- Returns:
70
- List of created PydanticBlock instances (with IDs, timestamps, etc.)
71
- """
72
- if not blocks:
73
- return []
74
-
75
- with db_registry.session() as session:
76
- block_models = [
77
- BlockModel(**block.model_dump(to_orm=True, exclude_none=True), organization_id=actor.organization_id) for block in blocks
78
- ]
79
-
80
- created_models = BlockModel.batch_create(items=block_models, db_session=session, actor=actor)
81
-
82
- # Convert back to Pydantic
83
- return [m.to_pydantic() for m in created_models]
84
-
85
105
  @enforce_types
86
106
  @trace_method
87
107
  async def batch_create_blocks_async(self, blocks: List[PydanticBlock], actor: PydanticUser) -> List[PydanticBlock]:
@@ -97,6 +117,11 @@ class BlockManager:
97
117
  return []
98
118
 
99
119
  async with db_registry.async_session() as session:
120
+ # Validate all blocks before creating any
121
+ for block in blocks:
122
+ block_data = block.model_dump(to_orm=True, exclude_none=True)
123
+ validate_block_creation(block_data)
124
+
100
125
  block_models = [
101
126
  BlockModel(**block.model_dump(to_orm=True, exclude_none=True), organization_id=actor.organization_id) for block in blocks
102
127
  ]
@@ -107,22 +132,6 @@ class BlockManager:
107
132
  await session.commit()
108
133
  return result
109
134
 
110
- @enforce_types
111
- @trace_method
112
- def update_block(self, block_id: str, block_update: BlockUpdate, actor: PydanticUser) -> PydanticBlock:
113
- """Update a block by its ID with the given BlockUpdate object."""
114
- # Safety check for block
115
-
116
- with db_registry.session() as session:
117
- block = BlockModel.read(db_session=session, identifier=block_id, actor=actor)
118
- update_data = block_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
119
-
120
- for key, value in update_data.items():
121
- setattr(block, key, value)
122
-
123
- block.update(db_session=session, actor=actor)
124
- return block.to_pydantic()
125
-
126
135
  @enforce_types
127
136
  @trace_method
128
137
  async def update_block_async(self, block_id: str, block_update: BlockUpdate, actor: PydanticUser) -> PydanticBlock:
@@ -133,6 +142,9 @@ class BlockManager:
133
142
  block = await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
134
143
  update_data = block_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
135
144
 
145
+ # Validate limit constraints before updating
146
+ validate_block_limit_constraint(update_data, block)
147
+
136
148
  for key, value in update_data.items():
137
149
  setattr(block, key, value)
138
150
 
@@ -141,19 +153,6 @@ class BlockManager:
141
153
  await session.commit()
142
154
  return pydantic_block
143
155
 
144
- @enforce_types
145
- @trace_method
146
- def delete_block(self, block_id: str, actor: PydanticUser) -> None:
147
- """Delete a block by its ID."""
148
- with db_registry.session() as session:
149
- # First, delete all references in blocks_agents table
150
- session.execute(delete(BlocksAgents).where(BlocksAgents.block_id == block_id))
151
- session.flush()
152
-
153
- # Then delete the block itself
154
- block = BlockModel.read(db_session=session, identifier=block_id)
155
- block.hard_delete(db_session=session, actor=actor)
156
-
157
156
  @enforce_types
158
157
  @trace_method
159
158
  async def delete_block_async(self, block_id: str, actor: PydanticUser) -> None:
@@ -352,17 +351,6 @@ class BlockManager:
352
351
 
353
352
  return [block.to_pydantic() for block in blocks]
354
353
 
355
- @enforce_types
356
- @trace_method
357
- def get_block_by_id(self, block_id: str, actor: Optional[PydanticUser] = None) -> Optional[PydanticBlock]:
358
- """Retrieve a block by its name."""
359
- with db_registry.session() as session:
360
- try:
361
- block = BlockModel.read(db_session=session, identifier=block_id, actor=actor)
362
- return block.to_pydantic()
363
- except NoResultFound:
364
- return None
365
-
366
354
  @enforce_types
367
355
  @trace_method
368
356
  async def get_block_by_id_async(self, block_id: str, actor: Optional[PydanticUser] = None) -> Optional[PydanticBlock]:
@@ -524,9 +512,90 @@ class BlockManager:
524
512
 
525
513
  # Block History Functions
526
514
 
515
+ @enforce_types
516
+ async def _move_block_to_sequence(self, session: AsyncSession, block: BlockModel, target_seq: int, actor: PydanticUser) -> BlockModel:
517
+ """
518
+ Internal helper that moves the 'block' to the specified 'target_seq' within BlockHistory.
519
+ 1) Find the BlockHistory row at sequence_number=target_seq
520
+ 2) Copy fields into the block
521
+ 3) Update and flush (no_commit=True) - the caller is responsible for final commit
522
+
523
+ Raises:
524
+ NoResultFound: if no BlockHistory row for (block_id, target_seq)
525
+ """
526
+ if not block.id:
527
+ raise ValueError("Block is missing an ID. Cannot move sequence.")
528
+
529
+ stmt = select(BlockHistory).filter(
530
+ BlockHistory.block_id == block.id,
531
+ BlockHistory.sequence_number == target_seq,
532
+ )
533
+ result = await session.execute(stmt)
534
+ target_entry = result.scalar_one_or_none()
535
+ if not target_entry:
536
+ raise NoResultFound(f"No BlockHistory row found for block_id={block.id} at sequence={target_seq}")
537
+
538
+ # Copy fields from target_entry to block
539
+ block.description = target_entry.description # type: ignore
540
+ block.label = target_entry.label # type: ignore
541
+ block.value = target_entry.value # type: ignore
542
+ block.limit = target_entry.limit # type: ignore
543
+ block.metadata_ = target_entry.metadata_ # type: ignore
544
+ block.current_history_entry_id = target_entry.id # type: ignore
545
+
546
+ # Update in DB (optimistic locking).
547
+ # We'll do a flush now; the caller does final commit.
548
+ updated_block = await block.update_async(db_session=session, actor=actor, no_commit=True)
549
+ return updated_block
550
+
527
551
  @enforce_types
528
552
  @trace_method
529
- def checkpoint_block(
553
+ async def bulk_update_block_values_async(
554
+ self, updates: Dict[str, str], actor: PydanticUser, return_hydrated: bool = False
555
+ ) -> Optional[List[PydanticBlock]]:
556
+ """
557
+ Bulk-update the `value` field for multiple blocks in one transaction.
558
+
559
+ Args:
560
+ updates: mapping of block_id -> new value
561
+ actor: the user performing the update (for org scoping, permissions, audit)
562
+ return_hydrated: whether to return the pydantic Block objects that were updated
563
+
564
+ Returns:
565
+ the updated Block objects as Pydantic schemas
566
+
567
+ Raises:
568
+ NoResultFound if any block_id doesn't exist or isn't visible to this actor
569
+ ValueError if any new value exceeds its block's limit
570
+ """
571
+ async with db_registry.async_session() as session:
572
+ query = select(BlockModel).where(BlockModel.id.in_(updates.keys()), BlockModel.organization_id == actor.organization_id)
573
+ result = await session.execute(query)
574
+ blocks = result.scalars().all()
575
+
576
+ found_ids = {b.id for b in blocks}
577
+ missing = set(updates.keys()) - found_ids
578
+ if missing:
579
+ logger.warning(f"Block IDs not found or inaccessible, skipping during bulk update: {missing!r}")
580
+
581
+ for block in blocks:
582
+ new_val = updates[block.id]
583
+ if len(new_val) > block.limit:
584
+ logger.warning(f"Value length ({len(new_val)}) exceeds limit ({block.limit}) for block {block.id!r}, truncating...")
585
+ new_val = new_val[: block.limit]
586
+ block.value = new_val
587
+
588
+ await session.commit()
589
+
590
+ if return_hydrated:
591
+ # TODO: implement for async
592
+ pass
593
+
594
+ return None
595
+
596
+ @enforce_types
597
+ @trace_method
598
+ async def checkpoint_block_async(
530
599
  self,
531
600
  block_id: str,
532
601
  actor: PydanticUser,
@@ -543,17 +612,17 @@ class BlockManager:
543
612
  strictly linear history.
544
613
  - A single commit at the end ensures atomicity.
545
614
  """
546
- with db_registry.session() as session:
615
+ async with db_registry.async_session() as session:
547
616
  # 1) Load the Block
548
617
  if use_preloaded_block is not None:
549
- block = session.merge(use_preloaded_block)
618
+ block = await session.merge(use_preloaded_block)
550
619
  else:
551
- block = BlockModel.read(db_session=session, identifier=block_id, actor=actor)
620
+ block = await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
552
621
 
553
622
  # 2) Identify the block's current checkpoint (if any)
554
623
  current_entry = None
555
624
  if block.current_history_entry_id:
556
- current_entry = session.get(BlockHistory, block.current_history_entry_id)
625
+ current_entry = await session.get(BlockHistory, block.current_history_entry_id)
557
626
 
558
627
  # The current sequence, or 0 if no checkpoints exist
559
628
  current_seq = current_entry.sequence_number if current_entry else 0
@@ -561,7 +630,13 @@ class BlockManager:
561
630
  # 3) Truncate any future checkpoints
562
631
  # If we are at seq=2, but there's a seq=3 or higher from a prior "redo chain",
563
632
  # remove those, so we maintain a strictly linear undo/redo stack.
564
- session.query(BlockHistory).filter(BlockHistory.block_id == block.id, BlockHistory.sequence_number > current_seq).delete()
633
+ stmt = select(BlockHistory).filter(BlockHistory.block_id == block.id, BlockHistory.sequence_number > current_seq)
634
+ result = await session.execute(stmt)
635
+ for entry in result.scalars():
636
+ session.delete(entry)
637
+
638
+ # Flush the deletes to ensure they're executed before we create a new entry
639
+ await session.flush()
565
640
 
566
641
  # 4) Determine the next sequence number
567
642
  next_seq = current_seq + 1
@@ -579,19 +654,19 @@ class BlockManager:
579
654
  actor_type=ActorType.LETTA_AGENT if agent_id else ActorType.LETTA_USER,
580
655
  actor_id=agent_id if agent_id else actor.id,
581
656
  )
582
- history_entry.create(session, actor=actor, no_commit=True)
657
+ await history_entry.create_async(session, actor=actor, no_commit=True)
583
658
 
584
659
  # 6) Update the block’s pointer to the new checkpoint
585
660
  block.current_history_entry_id = history_entry.id
586
661
 
587
662
  # 7) Flush changes, then commit once
588
- block = block.update(db_session=session, actor=actor, no_commit=True)
589
- session.commit()
663
+ block = await block.update_async(db_session=session, actor=actor, no_commit=True)
664
+ await session.commit()
590
665
 
591
666
  return block.to_pydantic()
592
667
 
593
668
  @enforce_types
594
- def _move_block_to_sequence(self, session: Session, block: BlockModel, target_seq: int, actor: PydanticUser) -> BlockModel:
669
+ async def _move_block_to_sequence(self, session: AsyncSession, block: BlockModel, target_seq: int, actor: PydanticUser) -> BlockModel:
595
670
  """
596
671
  Internal helper that moves the 'block' to the specified 'target_seq' within BlockHistory.
597
672
  1) Find the BlockHistory row at sequence_number=target_seq
@@ -604,14 +679,12 @@ class BlockManager:
604
679
  if not block.id:
605
680
  raise ValueError("Block is missing an ID. Cannot move sequence.")
606
681
 
607
- target_entry = (
608
- session.query(BlockHistory)
609
- .filter(
610
- BlockHistory.block_id == block.id,
611
- BlockHistory.sequence_number == target_seq,
612
- )
613
- .one_or_none()
682
+ stmt = select(BlockHistory).filter(
683
+ BlockHistory.block_id == block.id,
684
+ BlockHistory.sequence_number == target_seq,
614
685
  )
686
+ result = await session.execute(stmt)
687
+ target_entry = result.scalar_one_or_none()
615
688
  if not target_entry:
616
689
  raise NoResultFound(f"No BlockHistory row found for block_id={block.id} at sequence={target_seq}")
617
690
 
@@ -625,132 +698,102 @@ class BlockManager:
625
698
 
626
699
  # Update in DB (optimistic locking).
627
700
  # We'll do a flush now; the caller does final commit.
628
- updated_block = block.update(db_session=session, actor=actor, no_commit=True)
701
+ updated_block = await block.update_async(db_session=session, actor=actor, no_commit=True)
629
702
  return updated_block
630
703
 
631
704
  @enforce_types
632
705
  @trace_method
633
- def undo_checkpoint_block(self, block_id: str, actor: PydanticUser, use_preloaded_block: Optional[BlockModel] = None) -> PydanticBlock:
706
+ async def undo_checkpoint_block(
707
+ self, block_id: str, actor: PydanticUser, use_preloaded_block: Optional[BlockModel] = None
708
+ ) -> PydanticBlock:
634
709
  """
635
710
  Move the block to the immediately previous checkpoint in BlockHistory.
636
711
  If older sequences have been pruned, we jump to the largest sequence
637
712
  number that is still < current_seq.
638
713
  """
639
- with db_registry.session() as session:
714
+ async with db_registry.async_session() as session:
640
715
  # 1) Load the current block
641
716
  block = (
642
- session.merge(use_preloaded_block)
717
+ await session.merge(use_preloaded_block)
643
718
  if use_preloaded_block
644
- else BlockModel.read(db_session=session, identifier=block_id, actor=actor)
719
+ else await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
645
720
  )
646
721
 
647
722
  if not block.current_history_entry_id:
648
- raise ValueError(f"Block {block_id} has no history entry - cannot undo.")
723
+ raise LettaInvalidArgumentError(f"Block {block_id} has no history entry - cannot undo.", argument_name="block_id")
649
724
 
650
- current_entry = session.get(BlockHistory, block.current_history_entry_id)
725
+ current_entry = await session.get(BlockHistory, block.current_history_entry_id)
651
726
  if not current_entry:
652
727
  raise NoResultFound(f"BlockHistory row not found for id={block.current_history_entry_id}")
653
728
 
654
729
  current_seq = current_entry.sequence_number
655
730
 
656
731
  # 2) Find the largest sequence < current_seq
657
- previous_entry = (
658
- session.query(BlockHistory)
732
+ stmt = (
733
+ select(BlockHistory)
659
734
  .filter(BlockHistory.block_id == block.id, BlockHistory.sequence_number < current_seq)
660
735
  .order_by(BlockHistory.sequence_number.desc())
661
- .first()
736
+ .limit(1)
662
737
  )
738
+ result = await session.execute(stmt)
739
+ previous_entry = result.scalar_one_or_none()
663
740
  if not previous_entry:
664
741
  # No earlier checkpoint available
665
- raise ValueError(f"Block {block_id} is already at the earliest checkpoint (seq={current_seq}). Cannot undo further.")
742
+ raise LettaInvalidArgumentError(
743
+ f"Block {block_id} is already at the earliest checkpoint (seq={current_seq}). Cannot undo further.",
744
+ argument_name="block_id",
745
+ )
666
746
 
667
747
  # 3) Move to that sequence
668
- block = self._move_block_to_sequence(session, block, previous_entry.sequence_number, actor)
748
+ block = await self._move_block_to_sequence(session, block, previous_entry.sequence_number, actor)
669
749
 
670
750
  # 4) Commit
671
- session.commit()
751
+ await session.commit()
672
752
  return block.to_pydantic()
673
753
 
674
754
  @enforce_types
675
755
  @trace_method
676
- def redo_checkpoint_block(self, block_id: str, actor: PydanticUser, use_preloaded_block: Optional[BlockModel] = None) -> PydanticBlock:
756
+ async def redo_checkpoint_block(
757
+ self, block_id: str, actor: PydanticUser, use_preloaded_block: Optional[BlockModel] = None
758
+ ) -> PydanticBlock:
677
759
  """
678
760
  Move the block to the next checkpoint if it exists.
679
761
  If some middle checkpoints have been pruned, we jump to the smallest
680
762
  sequence > current_seq that remains.
681
763
  """
682
- with db_registry.session() as session:
764
+ async with db_registry.async_session() as session:
683
765
  block = (
684
- session.merge(use_preloaded_block)
766
+ await session.merge(use_preloaded_block)
685
767
  if use_preloaded_block
686
- else BlockModel.read(db_session=session, identifier=block_id, actor=actor)
768
+ else await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
687
769
  )
688
770
 
689
771
  if not block.current_history_entry_id:
690
- raise ValueError(f"Block {block_id} has no history entry - cannot redo.")
772
+ raise LettaInvalidArgumentError(f"Block {block_id} has no history entry - cannot redo.", argument_name="block_id")
691
773
 
692
- current_entry = session.get(BlockHistory, block.current_history_entry_id)
774
+ current_entry = await session.get(BlockHistory, block.current_history_entry_id)
693
775
  if not current_entry:
694
- raise NoResultFound(f"BlockHistory row not found for id={block.current_history_entry_id}")
776
+ raise LettaInvalidArgumentError(
777
+ f"BlockHistory row not found for id={block.current_history_entry_id}", argument_name="block_id"
778
+ )
695
779
 
696
780
  current_seq = current_entry.sequence_number
697
781
 
698
782
  # Find the smallest sequence that is > current_seq
699
- next_entry = (
700
- session.query(BlockHistory)
783
+ stmt = (
784
+ select(BlockHistory)
701
785
  .filter(BlockHistory.block_id == block.id, BlockHistory.sequence_number > current_seq)
702
786
  .order_by(BlockHistory.sequence_number.asc())
703
- .first()
787
+ .limit(1)
704
788
  )
789
+ result = await session.execute(stmt)
790
+ next_entry = result.scalar_one_or_none()
705
791
  if not next_entry:
706
- raise ValueError(f"Block {block_id} is at the highest checkpoint (seq={current_seq}). Cannot redo further.")
707
-
708
- block = self._move_block_to_sequence(session, block, next_entry.sequence_number, actor)
709
-
710
- session.commit()
711
- return block.to_pydantic()
712
-
713
- @enforce_types
714
- @trace_method
715
- async def bulk_update_block_values_async(
716
- self, updates: Dict[str, str], actor: PydanticUser, return_hydrated: bool = False
717
- ) -> Optional[List[PydanticBlock]]:
718
- """
719
- Bulk-update the `value` field for multiple blocks in one transaction.
720
-
721
- Args:
722
- updates: mapping of block_id -> new value
723
- actor: the user performing the update (for org scoping, permissions, audit)
724
- return_hydrated: whether to return the pydantic Block objects that were updated
725
-
726
- Returns:
727
- the updated Block objects as Pydantic schemas
728
-
729
- Raises:
730
- NoResultFound if any block_id doesn't exist or isn't visible to this actor
731
- ValueError if any new value exceeds its block's limit
732
- """
733
- async with db_registry.async_session() as session:
734
- query = select(BlockModel).where(BlockModel.id.in_(updates.keys()), BlockModel.organization_id == actor.organization_id)
735
- result = await session.execute(query)
736
- blocks = result.scalars().all()
792
+ raise LettaInvalidArgumentError(
793
+ f"Block {block_id} is at the highest checkpoint (seq={current_seq}). Cannot redo further.", argument_name="block_id"
794
+ )
737
795
 
738
- found_ids = {b.id for b in blocks}
739
- missing = set(updates.keys()) - found_ids
740
- if missing:
741
- logger.warning(f"Block IDs not found or inaccessible, skipping during bulk update: {missing!r}")
742
-
743
- for block in blocks:
744
- new_val = updates[block.id]
745
- if len(new_val) > block.limit:
746
- logger.warning(f"Value length ({len(new_val)}) exceeds limit ({block.limit}) for block {block.id!r}, truncating...")
747
- new_val = new_val[: block.limit]
748
- block.value = new_val
796
+ block = await self._move_block_to_sequence(session, block, next_entry.sequence_number, actor)
749
797
 
750
798
  await session.commit()
751
-
752
- if return_hydrated:
753
- # TODO: implement for async
754
- pass
755
-
756
- return None
799
+ return block.to_pydantic()
@@ -74,7 +74,7 @@ class AnthropicTokenCounter(TokenCounter):
74
74
  return await self.client.count_tokens(model=self.model, tools=tools)
75
75
 
76
76
  def convert_messages(self, messages: List[Any]) -> List[Dict[str, Any]]:
77
- return Message.to_anthropic_dicts_from_list(messages)
77
+ return Message.to_anthropic_dicts_from_list(messages, current_model=self.model)
78
78
 
79
79
 
80
80
  class TiktokenCounter(TokenCounter):
@@ -406,7 +406,7 @@ class FileManager:
406
406
  actor: PydanticUser,
407
407
  before: Optional[str] = None,
408
408
  after: Optional[str] = None,
409
- limit: Optional[int] = 50,
409
+ limit: Optional[int] = None,
410
410
  ascending: Optional[bool] = True,
411
411
  include_content: bool = False,
412
412
  strip_directory_prefix: bool = False,
@@ -300,6 +300,9 @@ class FileAgentManager:
300
300
  cursor: Optional[str] = None,
301
301
  limit: int = 20,
302
302
  is_open: Optional[bool] = None,
303
+ before: Optional[str] = None,
304
+ after: Optional[str] = None,
305
+ ascending: bool = False,
303
306
  ) -> tuple[List[PydanticFileAgent], Optional[str], bool]:
304
307
  """
305
308
  Return paginated file associations for an agent.
@@ -307,9 +310,12 @@ class FileAgentManager:
307
310
  Args:
308
311
  agent_id: The agent ID to get files for
309
312
  actor: User performing the action
310
- cursor: Pagination cursor (file-agent ID to start after)
313
+ cursor: Pagination cursor (file-agent ID to start after) - deprecated, use before/after
311
314
  limit: Maximum number of results to return
312
315
  is_open: Optional filter for open/closed status (None = all, True = open only, False = closed only)
316
+ before: File-agent ID cursor for pagination. Returns files that come before this ID in the specified sort order
317
+ after: File-agent ID cursor for pagination. Returns files that come after this ID in the specified sort order
318
+ ascending: Sort order (True = ascending by created_at/id, False = descending)
313
319
 
314
320
  Returns:
315
321
  Tuple of (file_agents, next_cursor, has_more)
@@ -325,14 +331,27 @@ class FileAgentManager:
325
331
  if is_open is not None:
326
332
  conditions.append(FileAgentModel.is_open == is_open)
327
333
 
328
- # apply cursor if provided (get records after this ID)
329
- if cursor:
334
+ # handle pagination cursors (support both old and new style)
335
+ if before:
336
+ conditions.append(FileAgentModel.id < before)
337
+ elif after:
338
+ conditions.append(FileAgentModel.id > after)
339
+ elif cursor:
340
+ # fallback to old cursor behavior for backwards compatibility
330
341
  conditions.append(FileAgentModel.id > cursor)
331
342
 
332
343
  query = select(FileAgentModel).where(and_(*conditions))
333
344
 
334
- # order by ID for stable pagination
335
- query = query.order_by(FileAgentModel.id)
345
+ # apply sorting based on pagination method
346
+ if before or after:
347
+ # For new cursor-based pagination, use created_at + id ordering
348
+ if ascending:
349
+ query = query.order_by(FileAgentModel.created_at.asc(), FileAgentModel.id.asc())
350
+ else:
351
+ query = query.order_by(FileAgentModel.created_at.desc(), FileAgentModel.id.desc())
352
+ else:
353
+ # For old cursor compatibility, maintain original behavior (ascending by ID)
354
+ query = query.order_by(FileAgentModel.id)
336
355
 
337
356
  # fetch limit + 1 to check if there are more results
338
357
  query = query.limit(limit + 1)
@@ -711,31 +730,3 @@ class FileAgentManager:
711
730
  if not assoc:
712
731
  raise NoResultFound(f"FileAgent(agent_id={agent_id}, file_name={file_name}) not found in org {actor.organization_id}")
713
732
  return assoc
714
-
715
- @enforce_types
716
- @trace_method
717
- async def get_files_agents_for_agents_async(self, agent_ids: List[str], actor: PydanticUser) -> List[PydanticFileAgent]:
718
- """
719
- Get all file-agent relationships for multiple agents in a single query.
720
-
721
- Args:
722
- agent_ids: List of agent IDs to find file-agent relationships for
723
- actor: User performing the action
724
-
725
- Returns:
726
- List[PydanticFileAgent]: List of file-agent relationships for these agents
727
- """
728
- if not agent_ids:
729
- return []
730
-
731
- async with db_registry.async_session() as session:
732
- query = select(FileAgentModel).where(
733
- FileAgentModel.agent_id.in_(agent_ids),
734
- FileAgentModel.organization_id == actor.organization_id,
735
- FileAgentModel.is_deleted == False,
736
- )
737
-
738
- result = await session.execute(query)
739
- file_agents_orm = result.scalars().all()
740
-
741
- return [file_agent.to_pydantic() for file_agent in file_agents_orm]