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
letta/services/agent_manager.py
CHANGED
@@ -15,6 +15,7 @@ from letta.constants import (
|
|
15
15
|
BASE_TOOLS,
|
16
16
|
BASE_VOICE_SLEEPTIME_CHAT_TOOLS,
|
17
17
|
BASE_VOICE_SLEEPTIME_TOOLS,
|
18
|
+
DEFAULT_TIMEZONE,
|
18
19
|
FILES_TOOLS,
|
19
20
|
MULTI_AGENT_TOOLS,
|
20
21
|
)
|
@@ -287,7 +288,7 @@ class AgentManager:
|
|
287
288
|
tool_rules = list(agent_create.tool_rules or [])
|
288
289
|
if agent_create.include_base_tool_rules:
|
289
290
|
for tn in tool_names:
|
290
|
-
if tn in {"send_message", "
|
291
|
+
if tn in {"send_message", "memory_finish_edits"}:
|
291
292
|
tool_rules.append(TerminalToolRule(tool_name=tn))
|
292
293
|
elif tn in (BASE_TOOLS + BASE_MEMORY_TOOLS + BASE_SLEEPTIME_TOOLS):
|
293
294
|
tool_rules.append(ContinueToolRule(tool_name=tn))
|
@@ -317,6 +318,7 @@ class AgentManager:
|
|
317
318
|
response_format=agent_create.response_format,
|
318
319
|
created_by_id=actor.id,
|
319
320
|
last_updated_by_id=actor.id,
|
321
|
+
timezone=agent_create.timezone,
|
320
322
|
)
|
321
323
|
|
322
324
|
if _test_only_force_id:
|
@@ -450,7 +452,7 @@ class AgentManager:
|
|
450
452
|
tool_rules = list(agent_create.tool_rules or [])
|
451
453
|
if agent_create.include_base_tool_rules:
|
452
454
|
for tn in tool_names:
|
453
|
-
if tn in {"send_message", "
|
455
|
+
if tn in {"send_message", "memory_finish_edits"}:
|
454
456
|
tool_rules.append(TerminalToolRule(tool_name=tn))
|
455
457
|
elif tn in (BASE_TOOLS + BASE_MEMORY_TOOLS + BASE_MEMORY_TOOLS_V2 + BASE_SLEEPTIME_TOOLS):
|
456
458
|
tool_rules.append(ContinueToolRule(tool_name=tn))
|
@@ -480,6 +482,7 @@ class AgentManager:
|
|
480
482
|
response_format=agent_create.response_format,
|
481
483
|
created_by_id=actor.id,
|
482
484
|
last_updated_by_id=actor.id,
|
485
|
+
timezone=agent_create.timezone if agent_create.timezone else DEFAULT_TIMEZONE,
|
483
486
|
)
|
484
487
|
|
485
488
|
if _test_only_force_id:
|
@@ -539,6 +542,7 @@ class AgentManager:
|
|
539
542
|
new_agent.message_ids = [msg.id for msg in init_messages]
|
540
543
|
|
541
544
|
await session.refresh(new_agent)
|
545
|
+
|
542
546
|
result = await new_agent.to_pydantic_async()
|
543
547
|
|
544
548
|
await self.message_manager.create_many_messages_async(pydantic_msgs=init_messages, actor=actor)
|
@@ -561,7 +565,9 @@ class AgentManager:
|
|
561
565
|
# Don't use anything else in the pregen sequence, instead use the provided sequence
|
562
566
|
init_messages = [system_message_obj]
|
563
567
|
init_messages.extend(
|
564
|
-
package_initial_message_sequence(
|
568
|
+
package_initial_message_sequence(
|
569
|
+
agent_state.id, supplied_initial_message_sequence, agent_state.llm_config.model, agent_state.timezone, actor
|
570
|
+
)
|
565
571
|
)
|
566
572
|
else:
|
567
573
|
init_messages = [
|
@@ -1424,6 +1430,7 @@ class AgentManager:
|
|
1424
1430
|
system_prompt=agent_state.system,
|
1425
1431
|
in_context_memory=agent_state.memory,
|
1426
1432
|
in_context_memory_last_edit=memory_edit_timestamp,
|
1433
|
+
timezone=agent_state.timezone,
|
1427
1434
|
previous_message_count=num_messages - len(agent_state.message_ids),
|
1428
1435
|
archival_memory_size=num_archival_memories,
|
1429
1436
|
)
|
@@ -1498,6 +1505,7 @@ class AgentManager:
|
|
1498
1505
|
system_prompt=agent_state.system,
|
1499
1506
|
in_context_memory=agent_state.memory,
|
1500
1507
|
in_context_memory_last_edit=memory_edit_timestamp,
|
1508
|
+
timezone=agent_state.timezone,
|
1501
1509
|
previous_message_count=num_messages - len(agent_state.message_ids),
|
1502
1510
|
archival_memory_size=num_archival_memories,
|
1503
1511
|
tool_rules_solver=tool_rules_solver,
|
@@ -1699,6 +1707,13 @@ class AgentManager:
|
|
1699
1707
|
|
1700
1708
|
return agent_state
|
1701
1709
|
|
1710
|
+
@trace_method
|
1711
|
+
@enforce_types
|
1712
|
+
async def refresh_file_blocks(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState:
|
1713
|
+
file_blocks = await self.file_agent_manager.list_files_for_agent(agent_id=agent_state.id, actor=actor, return_as_blocks=True)
|
1714
|
+
agent_state.memory.file_blocks = [b for b in file_blocks if b is not None]
|
1715
|
+
return agent_state
|
1716
|
+
|
1702
1717
|
# ======================================================================================================================
|
1703
1718
|
# Source Management
|
1704
1719
|
# ======================================================================================================================
|
@@ -1954,25 +1969,61 @@ class AgentManager:
|
|
1954
1969
|
@trace_method
|
1955
1970
|
@enforce_types
|
1956
1971
|
def attach_block(self, agent_id: str, block_id: str, actor: PydanticUser) -> PydanticAgentState:
|
1957
|
-
"""Attaches a block to an agent."""
|
1972
|
+
"""Attaches a block to an agent. For sleeptime agents, also attaches to paired agents in the same group."""
|
1958
1973
|
with db_registry.session() as session:
|
1959
1974
|
agent = AgentModel.read(db_session=session, identifier=agent_id, actor=actor)
|
1960
1975
|
block = BlockModel.read(db_session=session, identifier=block_id, actor=actor)
|
1961
1976
|
|
1977
|
+
# Attach block to the main agent
|
1962
1978
|
agent.core_memory.append(block)
|
1963
|
-
agent.update(session, actor=actor)
|
1979
|
+
agent.update(session, actor=actor, no_commit=True)
|
1980
|
+
|
1981
|
+
# If agent is part of a sleeptime group, attach block to the sleeptime_agent
|
1982
|
+
if agent.multi_agent_group and agent.multi_agent_group.manager_type == ManagerType.sleeptime:
|
1983
|
+
group = agent.multi_agent_group
|
1984
|
+
# Find the sleeptime_agent in the group
|
1985
|
+
for other_agent_id in group.agent_ids or []:
|
1986
|
+
if other_agent_id != agent_id:
|
1987
|
+
try:
|
1988
|
+
other_agent = AgentModel.read(db_session=session, identifier=other_agent_id, actor=actor)
|
1989
|
+
if other_agent.agent_type == AgentType.sleeptime_agent and block not in other_agent.core_memory:
|
1990
|
+
other_agent.core_memory.append(block)
|
1991
|
+
other_agent.update(session, actor=actor, no_commit=True)
|
1992
|
+
except NoResultFound:
|
1993
|
+
# Agent might not exist anymore, skip
|
1994
|
+
continue
|
1995
|
+
session.commit()
|
1996
|
+
|
1964
1997
|
return agent.to_pydantic()
|
1965
1998
|
|
1966
1999
|
@trace_method
|
1967
2000
|
@enforce_types
|
1968
2001
|
async def attach_block_async(self, agent_id: str, block_id: str, actor: PydanticUser) -> PydanticAgentState:
|
1969
|
-
"""Attaches a block to an agent."""
|
2002
|
+
"""Attaches a block to an agent. For sleeptime agents, also attaches to paired agents in the same group."""
|
1970
2003
|
async with db_registry.async_session() as session:
|
1971
2004
|
agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
|
1972
2005
|
block = await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
|
1973
2006
|
|
2007
|
+
# Attach block to the main agent
|
1974
2008
|
agent.core_memory.append(block)
|
1975
|
-
await agent.update_async(session
|
2009
|
+
await agent.update_async(session)
|
2010
|
+
|
2011
|
+
# If agent is part of a sleeptime group, attach block to the sleeptime_agent
|
2012
|
+
if agent.multi_agent_group and agent.multi_agent_group.manager_type == ManagerType.sleeptime:
|
2013
|
+
group = agent.multi_agent_group
|
2014
|
+
# Find the sleeptime_agent in the group
|
2015
|
+
for other_agent_id in group.agent_ids or []:
|
2016
|
+
if other_agent_id != agent_id:
|
2017
|
+
try:
|
2018
|
+
other_agent = await AgentModel.read_async(db_session=session, identifier=other_agent_id, actor=actor)
|
2019
|
+
if other_agent.agent_type == AgentType.sleeptime_agent and block not in other_agent.core_memory:
|
2020
|
+
other_agent.core_memory.append(block)
|
2021
|
+
await other_agent.update_async(session, actor=actor, no_commit=True)
|
2022
|
+
except NoResultFound:
|
2023
|
+
# Agent might not exist anymore, skip
|
2024
|
+
continue
|
2025
|
+
session.commit()
|
2026
|
+
|
1976
2027
|
return await agent.to_pydantic_async()
|
1977
2028
|
|
1978
2029
|
@trace_method
|
letta/services/block_manager.py
CHANGED
@@ -7,12 +7,12 @@ from sqlalchemy.orm import Session
|
|
7
7
|
from letta.log import get_logger
|
8
8
|
from letta.orm.block import Block as BlockModel
|
9
9
|
from letta.orm.block_history import BlockHistory
|
10
|
-
from letta.orm.enums import ActorType
|
11
10
|
from letta.orm.errors import NoResultFound
|
12
11
|
from letta.otel.tracing import trace_method
|
13
12
|
from letta.schemas.agent import AgentState as PydanticAgentState
|
14
13
|
from letta.schemas.block import Block as PydanticBlock
|
15
14
|
from letta.schemas.block import BlockUpdate
|
15
|
+
from letta.schemas.enums import ActorType
|
16
16
|
from letta.schemas.user import User as PydanticUser
|
17
17
|
from letta.server.db import db_registry
|
18
18
|
from letta.utils import enforce_types
|
@@ -99,10 +99,11 @@ class LineChunker:
|
|
99
99
|
return [line for line in lines if line.strip()]
|
100
100
|
|
101
101
|
def chunk_text(
|
102
|
-
self,
|
102
|
+
self, file_metadata: FileMetadata, start: Optional[int] = None, end: Optional[int] = None, add_metadata: bool = True
|
103
103
|
) -> List[str]:
|
104
104
|
"""Content-aware text chunking based on file type"""
|
105
105
|
strategy = self._determine_chunking_strategy(file_metadata)
|
106
|
+
text = file_metadata.content
|
106
107
|
|
107
108
|
# Apply the appropriate chunking strategy
|
108
109
|
if strategy == ChunkingStrategy.DOCUMENTATION:
|
@@ -75,21 +75,14 @@ class FileProcessor:
|
|
75
75
|
|
76
76
|
# update file with raw text
|
77
77
|
raw_markdown_text = "".join([page.markdown for page in ocr_response.pages])
|
78
|
-
file_metadata = await self.file_manager.upsert_file_content(file_id=file_metadata.id, text=raw_markdown_text, actor=self.actor)
|
79
78
|
file_metadata = await self.file_manager.update_file_status(
|
80
79
|
file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.EMBEDDING
|
81
80
|
)
|
82
|
-
|
83
|
-
# Insert to agent context window
|
84
|
-
# TODO: Rethink this line chunking mechanism
|
85
|
-
content_lines = self.line_chunker.chunk_text(text=raw_markdown_text, file_metadata=file_metadata)
|
86
|
-
visible_content = "\n".join(content_lines)
|
81
|
+
file_metadata = await self.file_manager.upsert_file_content(file_id=file_metadata.id, text=raw_markdown_text, actor=self.actor)
|
87
82
|
|
88
83
|
await server.insert_file_into_context_windows(
|
89
84
|
source_id=source_id,
|
90
|
-
|
91
|
-
file_id=file_metadata.id,
|
92
|
-
file_name=file_metadata.file_name,
|
85
|
+
file_metadata_with_content=file_metadata,
|
93
86
|
actor=self.actor,
|
94
87
|
agent_states=agent_states,
|
95
88
|
)
|
@@ -3,6 +3,7 @@ from typing import List, Optional
|
|
3
3
|
|
4
4
|
from sqlalchemy import and_, func, select, update
|
5
5
|
|
6
|
+
from letta.constants import MAX_FILES_OPEN
|
6
7
|
from letta.orm.errors import NoResultFound
|
7
8
|
from letta.orm.files_agents import FileAgent as FileAgentModel
|
8
9
|
from letta.otel.tracing import trace_method
|
@@ -27,51 +28,66 @@ class FileAgentManager:
|
|
27
28
|
actor: PydanticUser,
|
28
29
|
is_open: bool = True,
|
29
30
|
visible_content: Optional[str] = None,
|
30
|
-
) -> PydanticFileAgent:
|
31
|
+
) -> tuple[PydanticFileAgent, List[str]]:
|
31
32
|
"""
|
32
|
-
Idempotently attach *file_id* to *agent_id
|
33
|
+
Idempotently attach *file_id* to *agent_id* with LRU enforcement.
|
33
34
|
|
34
35
|
• If the row already exists → update `is_open`, `visible_content`
|
35
36
|
and always refresh `last_accessed_at`.
|
36
37
|
• Otherwise create a brand-new association.
|
38
|
+
• If is_open=True, enforces MAX_FILES_OPEN using LRU eviction.
|
39
|
+
|
40
|
+
Returns:
|
41
|
+
Tuple of (file_agent, closed_file_names)
|
37
42
|
"""
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
FileAgentModel.file_id == file_id,
|
43
|
-
FileAgentModel.file_name == file_name,
|
44
|
-
FileAgentModel.organization_id == actor.organization_id,
|
45
|
-
)
|
43
|
+
if is_open:
|
44
|
+
# Use the efficient LRU + open method
|
45
|
+
closed_files, was_already_open = await self.enforce_max_open_files_and_open(
|
46
|
+
agent_id=agent_id, file_id=file_id, file_name=file_name, actor=actor, visible_content=visible_content or ""
|
46
47
|
)
|
47
|
-
existing = await session.scalar(query)
|
48
48
|
|
49
|
-
|
49
|
+
# Get the updated file agent to return
|
50
|
+
file_agent = await self.get_file_agent_by_id(agent_id=agent_id, file_id=file_id, actor=actor)
|
51
|
+
return file_agent, closed_files
|
52
|
+
else:
|
53
|
+
# Original logic for is_open=False
|
54
|
+
async with db_registry.async_session() as session:
|
55
|
+
query = select(FileAgentModel).where(
|
56
|
+
and_(
|
57
|
+
FileAgentModel.agent_id == agent_id,
|
58
|
+
FileAgentModel.file_id == file_id,
|
59
|
+
FileAgentModel.file_name == file_name,
|
60
|
+
FileAgentModel.organization_id == actor.organization_id,
|
61
|
+
)
|
62
|
+
)
|
63
|
+
existing = await session.scalar(query)
|
50
64
|
|
51
|
-
|
52
|
-
# update only the fields that actually changed
|
53
|
-
if existing.is_open != is_open:
|
54
|
-
existing.is_open = is_open
|
65
|
+
now_ts = datetime.now(timezone.utc)
|
55
66
|
|
56
|
-
if
|
57
|
-
|
67
|
+
if existing:
|
68
|
+
# update only the fields that actually changed
|
69
|
+
if existing.is_open != is_open:
|
70
|
+
existing.is_open = is_open
|
58
71
|
|
59
|
-
|
72
|
+
if visible_content is not None and existing.visible_content != visible_content:
|
73
|
+
existing.visible_content = visible_content
|
60
74
|
|
61
|
-
|
62
|
-
return existing.to_pydantic()
|
75
|
+
existing.last_accessed_at = now_ts
|
63
76
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
77
|
+
await existing.update_async(session, actor=actor)
|
78
|
+
return existing.to_pydantic(), []
|
79
|
+
|
80
|
+
assoc = FileAgentModel(
|
81
|
+
agent_id=agent_id,
|
82
|
+
file_id=file_id,
|
83
|
+
file_name=file_name,
|
84
|
+
organization_id=actor.organization_id,
|
85
|
+
is_open=is_open,
|
86
|
+
visible_content=visible_content,
|
87
|
+
last_accessed_at=now_ts,
|
88
|
+
)
|
89
|
+
await assoc.create_async(session, actor=actor)
|
90
|
+
return assoc.to_pydantic(), []
|
75
91
|
|
76
92
|
@enforce_types
|
77
93
|
@trace_method
|
@@ -192,10 +208,7 @@ class FileAgentManager:
|
|
192
208
|
@enforce_types
|
193
209
|
@trace_method
|
194
210
|
async def list_files_for_agent(
|
195
|
-
self,
|
196
|
-
agent_id: str,
|
197
|
-
actor: PydanticUser,
|
198
|
-
is_open_only: bool = False,
|
211
|
+
self, agent_id: str, actor: PydanticUser, is_open_only: bool = False, return_as_blocks: bool = False
|
199
212
|
) -> List[PydanticFileAgent]:
|
200
213
|
"""Return associations for *agent_id* (filtering by `is_open` if asked)."""
|
201
214
|
async with db_registry.async_session() as session:
|
@@ -207,7 +220,11 @@ class FileAgentManager:
|
|
207
220
|
conditions.append(FileAgentModel.is_open.is_(True))
|
208
221
|
|
209
222
|
rows = (await session.execute(select(FileAgentModel).where(and_(*conditions)))).scalars().all()
|
210
|
-
|
223
|
+
|
224
|
+
if return_as_blocks:
|
225
|
+
return [r.to_pydantic_block() for r in rows]
|
226
|
+
else:
|
227
|
+
return [r.to_pydantic() for r in rows]
|
211
228
|
|
212
229
|
@enforce_types
|
213
230
|
@trace_method
|
@@ -246,6 +263,129 @@ class FileAgentManager:
|
|
246
263
|
await session.execute(stmt)
|
247
264
|
await session.commit()
|
248
265
|
|
266
|
+
@enforce_types
|
267
|
+
@trace_method
|
268
|
+
async def mark_access_bulk(self, *, agent_id: str, file_names: List[str], actor: PydanticUser) -> None:
|
269
|
+
"""Update `last_accessed_at = now()` for multiple files by name without loading rows."""
|
270
|
+
if not file_names:
|
271
|
+
return
|
272
|
+
|
273
|
+
async with db_registry.async_session() as session:
|
274
|
+
stmt = (
|
275
|
+
update(FileAgentModel)
|
276
|
+
.where(
|
277
|
+
FileAgentModel.agent_id == agent_id,
|
278
|
+
FileAgentModel.file_name.in_(file_names),
|
279
|
+
FileAgentModel.organization_id == actor.organization_id,
|
280
|
+
)
|
281
|
+
.values(last_accessed_at=func.now())
|
282
|
+
)
|
283
|
+
await session.execute(stmt)
|
284
|
+
await session.commit()
|
285
|
+
|
286
|
+
@enforce_types
|
287
|
+
@trace_method
|
288
|
+
async def enforce_max_open_files_and_open(
|
289
|
+
self, *, agent_id: str, file_id: str, file_name: str, actor: PydanticUser, visible_content: str
|
290
|
+
) -> tuple[List[str], bool]:
|
291
|
+
"""
|
292
|
+
Efficiently handle LRU eviction and file opening in a single transaction.
|
293
|
+
|
294
|
+
Args:
|
295
|
+
agent_id: ID of the agent
|
296
|
+
file_id: ID of the file to open
|
297
|
+
file_name: Name of the file to open
|
298
|
+
actor: User performing the action
|
299
|
+
visible_content: Content to set for the opened file
|
300
|
+
|
301
|
+
Returns:
|
302
|
+
Tuple of (closed_file_names, file_was_already_open)
|
303
|
+
"""
|
304
|
+
async with db_registry.async_session() as session:
|
305
|
+
# Single query to get ALL open files for this agent, ordered by last_accessed_at (oldest first)
|
306
|
+
open_files_query = (
|
307
|
+
select(FileAgentModel)
|
308
|
+
.where(
|
309
|
+
and_(
|
310
|
+
FileAgentModel.agent_id == agent_id,
|
311
|
+
FileAgentModel.organization_id == actor.organization_id,
|
312
|
+
FileAgentModel.is_open.is_(True),
|
313
|
+
)
|
314
|
+
)
|
315
|
+
.order_by(FileAgentModel.last_accessed_at.asc()) # Oldest first for LRU
|
316
|
+
)
|
317
|
+
|
318
|
+
all_open_files = (await session.execute(open_files_query)).scalars().all()
|
319
|
+
|
320
|
+
# Check if the target file exists (open or closed)
|
321
|
+
target_file_query = select(FileAgentModel).where(
|
322
|
+
and_(
|
323
|
+
FileAgentModel.agent_id == agent_id,
|
324
|
+
FileAgentModel.organization_id == actor.organization_id,
|
325
|
+
FileAgentModel.file_name == file_name,
|
326
|
+
)
|
327
|
+
)
|
328
|
+
file_to_open = await session.scalar(target_file_query)
|
329
|
+
|
330
|
+
# Separate the file we're opening from others (only if it's currently open)
|
331
|
+
other_open_files = []
|
332
|
+
for file_agent in all_open_files:
|
333
|
+
if file_agent.file_name != file_name:
|
334
|
+
other_open_files.append(file_agent)
|
335
|
+
|
336
|
+
file_was_already_open = file_to_open is not None and file_to_open.is_open
|
337
|
+
|
338
|
+
# Calculate how many files need to be closed
|
339
|
+
current_other_count = len(other_open_files)
|
340
|
+
target_other_count = MAX_FILES_OPEN - 1 # Reserve 1 slot for file we're opening
|
341
|
+
|
342
|
+
closed_file_names = []
|
343
|
+
if current_other_count > target_other_count:
|
344
|
+
files_to_close_count = current_other_count - target_other_count
|
345
|
+
files_to_close = other_open_files[:files_to_close_count] # Take oldest
|
346
|
+
|
347
|
+
# Bulk close files using a single UPDATE query
|
348
|
+
file_ids_to_close = [f.file_id for f in files_to_close]
|
349
|
+
closed_file_names = [f.file_name for f in files_to_close]
|
350
|
+
|
351
|
+
if file_ids_to_close:
|
352
|
+
close_stmt = (
|
353
|
+
update(FileAgentModel)
|
354
|
+
.where(
|
355
|
+
and_(
|
356
|
+
FileAgentModel.agent_id == agent_id,
|
357
|
+
FileAgentModel.file_id.in_(file_ids_to_close),
|
358
|
+
FileAgentModel.organization_id == actor.organization_id,
|
359
|
+
)
|
360
|
+
)
|
361
|
+
.values(is_open=False, visible_content=None)
|
362
|
+
)
|
363
|
+
await session.execute(close_stmt)
|
364
|
+
|
365
|
+
# Open the target file (update or create)
|
366
|
+
now_ts = datetime.now(timezone.utc)
|
367
|
+
|
368
|
+
if file_to_open:
|
369
|
+
# Update existing file
|
370
|
+
file_to_open.is_open = True
|
371
|
+
file_to_open.visible_content = visible_content
|
372
|
+
file_to_open.last_accessed_at = now_ts
|
373
|
+
await file_to_open.update_async(session, actor=actor)
|
374
|
+
else:
|
375
|
+
# Create new file association
|
376
|
+
new_file_agent = FileAgentModel(
|
377
|
+
agent_id=agent_id,
|
378
|
+
file_id=file_id,
|
379
|
+
file_name=file_name,
|
380
|
+
organization_id=actor.organization_id,
|
381
|
+
is_open=True,
|
382
|
+
visible_content=visible_content,
|
383
|
+
last_accessed_at=now_ts,
|
384
|
+
)
|
385
|
+
await new_file_agent.create_async(session, actor=actor)
|
386
|
+
|
387
|
+
return closed_file_names, file_was_already_open
|
388
|
+
|
249
389
|
async def _get_association_by_file_id(self, session, agent_id: str, file_id: str, actor: PydanticUser) -> FileAgentModel:
|
250
390
|
q = select(FileAgentModel).where(
|
251
391
|
and_(
|