letta-nightly 0.11.7.dev20251006104136__py3-none-any.whl → 0.11.7.dev20251008104128__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/adapters/letta_llm_adapter.py +1 -0
- letta/adapters/letta_llm_request_adapter.py +0 -1
- letta/adapters/letta_llm_stream_adapter.py +7 -2
- letta/adapters/simple_llm_request_adapter.py +88 -0
- letta/adapters/simple_llm_stream_adapter.py +192 -0
- letta/agents/agent_loop.py +6 -0
- letta/agents/ephemeral_summary_agent.py +2 -1
- letta/agents/helpers.py +142 -6
- letta/agents/letta_agent.py +13 -33
- letta/agents/letta_agent_batch.py +2 -4
- letta/agents/letta_agent_v2.py +87 -77
- letta/agents/letta_agent_v3.py +899 -0
- letta/agents/voice_agent.py +2 -6
- letta/constants.py +8 -4
- letta/errors.py +40 -0
- letta/functions/function_sets/base.py +84 -4
- letta/functions/function_sets/multi_agent.py +0 -3
- letta/functions/schema_generator.py +113 -71
- letta/groups/dynamic_multi_agent.py +3 -2
- letta/groups/helpers.py +1 -2
- letta/groups/round_robin_multi_agent.py +3 -2
- letta/groups/sleeptime_multi_agent.py +3 -2
- letta/groups/sleeptime_multi_agent_v2.py +1 -1
- letta/groups/sleeptime_multi_agent_v3.py +17 -17
- letta/groups/supervisor_multi_agent.py +84 -80
- letta/helpers/converters.py +3 -0
- letta/helpers/message_helper.py +4 -0
- letta/helpers/tool_rule_solver.py +92 -5
- letta/interfaces/anthropic_streaming_interface.py +409 -0
- letta/interfaces/gemini_streaming_interface.py +296 -0
- letta/interfaces/openai_streaming_interface.py +752 -1
- letta/llm_api/anthropic_client.py +126 -16
- letta/llm_api/bedrock_client.py +4 -2
- letta/llm_api/deepseek_client.py +4 -1
- letta/llm_api/google_vertex_client.py +123 -42
- letta/llm_api/groq_client.py +4 -1
- letta/llm_api/llm_api_tools.py +11 -4
- letta/llm_api/llm_client_base.py +6 -2
- letta/llm_api/openai.py +32 -2
- letta/llm_api/openai_client.py +423 -18
- letta/llm_api/xai_client.py +4 -1
- letta/main.py +9 -5
- letta/memory.py +1 -0
- letta/orm/__init__.py +1 -1
- letta/orm/agent.py +10 -0
- letta/orm/block.py +7 -16
- letta/orm/blocks_agents.py +8 -2
- letta/orm/files_agents.py +2 -0
- letta/orm/job.py +7 -5
- letta/orm/mcp_oauth.py +1 -0
- letta/orm/message.py +21 -6
- letta/orm/organization.py +2 -0
- letta/orm/provider.py +6 -2
- letta/orm/run.py +71 -0
- letta/orm/sandbox_config.py +7 -1
- letta/orm/sqlalchemy_base.py +0 -306
- letta/orm/step.py +6 -5
- letta/orm/step_metrics.py +5 -5
- letta/otel/tracing.py +28 -3
- letta/plugins/defaults.py +4 -4
- letta/prompts/system_prompts/__init__.py +2 -0
- letta/prompts/system_prompts/letta_v1.py +25 -0
- letta/schemas/agent.py +3 -2
- letta/schemas/agent_file.py +9 -3
- letta/schemas/block.py +23 -10
- letta/schemas/enums.py +21 -2
- letta/schemas/job.py +17 -4
- letta/schemas/letta_message_content.py +71 -2
- letta/schemas/letta_stop_reason.py +5 -5
- letta/schemas/llm_config.py +53 -3
- letta/schemas/memory.py +1 -1
- letta/schemas/message.py +504 -117
- letta/schemas/openai/responses_request.py +64 -0
- letta/schemas/providers/__init__.py +2 -0
- letta/schemas/providers/anthropic.py +16 -0
- letta/schemas/providers/ollama.py +115 -33
- letta/schemas/providers/openrouter.py +52 -0
- letta/schemas/providers/vllm.py +2 -1
- letta/schemas/run.py +48 -42
- letta/schemas/step.py +2 -2
- letta/schemas/step_metrics.py +1 -1
- letta/schemas/tool.py +15 -107
- letta/schemas/tool_rule.py +88 -5
- letta/serialize_schemas/marshmallow_agent.py +1 -0
- letta/server/db.py +86 -408
- letta/server/rest_api/app.py +61 -10
- letta/server/rest_api/dependencies.py +14 -0
- letta/server/rest_api/redis_stream_manager.py +19 -8
- letta/server/rest_api/routers/v1/agents.py +364 -292
- letta/server/rest_api/routers/v1/blocks.py +14 -20
- letta/server/rest_api/routers/v1/identities.py +45 -110
- letta/server/rest_api/routers/v1/internal_templates.py +21 -0
- letta/server/rest_api/routers/v1/jobs.py +23 -6
- letta/server/rest_api/routers/v1/messages.py +1 -1
- letta/server/rest_api/routers/v1/runs.py +126 -85
- letta/server/rest_api/routers/v1/sandbox_configs.py +10 -19
- letta/server/rest_api/routers/v1/tools.py +281 -594
- letta/server/rest_api/routers/v1/voice.py +1 -1
- letta/server/rest_api/streaming_response.py +29 -29
- letta/server/rest_api/utils.py +122 -64
- letta/server/server.py +160 -887
- letta/services/agent_manager.py +236 -919
- letta/services/agent_serialization_manager.py +16 -0
- letta/services/archive_manager.py +0 -100
- letta/services/block_manager.py +211 -168
- letta/services/file_manager.py +1 -1
- letta/services/files_agents_manager.py +24 -33
- letta/services/group_manager.py +0 -142
- letta/services/helpers/agent_manager_helper.py +7 -2
- letta/services/helpers/run_manager_helper.py +85 -0
- letta/services/job_manager.py +96 -411
- letta/services/lettuce/__init__.py +6 -0
- letta/services/lettuce/lettuce_client_base.py +86 -0
- letta/services/mcp_manager.py +38 -6
- letta/services/message_manager.py +165 -362
- letta/services/organization_manager.py +0 -36
- letta/services/passage_manager.py +0 -345
- letta/services/provider_manager.py +0 -80
- letta/services/run_manager.py +301 -0
- letta/services/sandbox_config_manager.py +0 -234
- letta/services/step_manager.py +62 -39
- letta/services/summarizer/summarizer.py +9 -7
- letta/services/telemetry_manager.py +0 -16
- letta/services/tool_executor/builtin_tool_executor.py +35 -0
- letta/services/tool_executor/core_tool_executor.py +397 -2
- letta/services/tool_executor/files_tool_executor.py +3 -3
- letta/services/tool_executor/multi_agent_tool_executor.py +30 -15
- letta/services/tool_executor/tool_execution_manager.py +6 -8
- letta/services/tool_executor/tool_executor_base.py +3 -3
- letta/services/tool_manager.py +85 -339
- letta/services/tool_sandbox/base.py +24 -13
- letta/services/tool_sandbox/e2b_sandbox.py +16 -1
- letta/services/tool_schema_generator.py +123 -0
- letta/services/user_manager.py +0 -99
- letta/settings.py +20 -4
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/METADATA +3 -5
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/RECORD +140 -132
- letta/agents/temporal/activities/__init__.py +0 -4
- letta/agents/temporal/activities/example_activity.py +0 -7
- letta/agents/temporal/activities/prepare_messages.py +0 -10
- letta/agents/temporal/temporal_agent_workflow.py +0 -56
- letta/agents/temporal/types.py +0 -25
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/licenses/LICENSE +0 -0
letta/services/block_manager.py
CHANGED
@@ -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.
|
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
|
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.
|
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.
|
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
|
-
|
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.
|
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.
|
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:
|
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
|
-
|
608
|
-
|
609
|
-
.
|
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.
|
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(
|
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.
|
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.
|
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
|
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
|
-
|
658
|
-
|
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
|
-
.
|
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
|
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(
|
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.
|
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.
|
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
|
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
|
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
|
-
|
700
|
-
|
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
|
-
.
|
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
|
707
|
-
|
708
|
-
|
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
|
-
|
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()
|
letta/services/file_manager.py
CHANGED
@@ -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] =
|
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
|
-
#
|
329
|
-
if
|
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
|
-
#
|
335
|
-
|
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]
|