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.
Files changed (78) hide show
  1. letta/agent.py +16 -12
  2. letta/agents/base_agent.py +4 -1
  3. letta/agents/helpers.py +35 -3
  4. letta/agents/letta_agent.py +132 -106
  5. letta/agents/letta_agent_batch.py +4 -3
  6. letta/agents/voice_agent.py +12 -2
  7. letta/agents/voice_sleeptime_agent.py +12 -2
  8. letta/constants.py +24 -3
  9. letta/data_sources/redis_client.py +6 -0
  10. letta/errors.py +5 -0
  11. letta/functions/function_sets/files.py +10 -3
  12. letta/functions/function_sets/multi_agent.py +0 -32
  13. letta/groups/sleeptime_multi_agent_v2.py +6 -0
  14. letta/helpers/converters.py +4 -1
  15. letta/helpers/datetime_helpers.py +16 -23
  16. letta/helpers/message_helper.py +5 -2
  17. letta/helpers/tool_rule_solver.py +29 -2
  18. letta/interfaces/openai_streaming_interface.py +9 -2
  19. letta/llm_api/anthropic.py +11 -1
  20. letta/llm_api/anthropic_client.py +14 -3
  21. letta/llm_api/aws_bedrock.py +29 -15
  22. letta/llm_api/bedrock_client.py +74 -0
  23. letta/llm_api/google_ai_client.py +7 -3
  24. letta/llm_api/google_vertex_client.py +18 -4
  25. letta/llm_api/llm_client.py +7 -0
  26. letta/llm_api/openai_client.py +13 -0
  27. letta/orm/agent.py +5 -0
  28. letta/orm/block_history.py +1 -1
  29. letta/orm/enums.py +6 -25
  30. letta/orm/job.py +1 -2
  31. letta/orm/llm_batch_items.py +1 -1
  32. letta/orm/mcp_server.py +1 -1
  33. letta/orm/passage.py +7 -1
  34. letta/orm/sqlalchemy_base.py +7 -5
  35. letta/orm/tool.py +2 -1
  36. letta/schemas/agent.py +34 -10
  37. letta/schemas/enums.py +42 -1
  38. letta/schemas/job.py +6 -3
  39. letta/schemas/letta_request.py +4 -0
  40. letta/schemas/llm_batch_job.py +7 -2
  41. letta/schemas/memory.py +2 -2
  42. letta/schemas/providers.py +32 -6
  43. letta/schemas/run.py +1 -1
  44. letta/schemas/tool_rule.py +40 -12
  45. letta/serialize_schemas/pydantic_agent_schema.py +9 -2
  46. letta/server/rest_api/app.py +3 -2
  47. letta/server/rest_api/routers/v1/agents.py +25 -22
  48. letta/server/rest_api/routers/v1/runs.py +2 -3
  49. letta/server/rest_api/routers/v1/sources.py +31 -0
  50. letta/server/rest_api/routers/v1/voice.py +1 -0
  51. letta/server/rest_api/utils.py +38 -13
  52. letta/server/server.py +52 -21
  53. letta/services/agent_manager.py +58 -7
  54. letta/services/block_manager.py +1 -1
  55. letta/services/file_processor/chunker/line_chunker.py +2 -1
  56. letta/services/file_processor/file_processor.py +2 -9
  57. letta/services/files_agents_manager.py +177 -37
  58. letta/services/helpers/agent_manager_helper.py +77 -48
  59. letta/services/helpers/tool_parser_helper.py +2 -1
  60. letta/services/job_manager.py +33 -2
  61. letta/services/llm_batch_manager.py +1 -1
  62. letta/services/provider_manager.py +6 -4
  63. letta/services/tool_executor/core_tool_executor.py +1 -1
  64. letta/services/tool_executor/files_tool_executor.py +99 -30
  65. letta/services/tool_executor/multi_agent_tool_executor.py +1 -17
  66. letta/services/tool_executor/tool_execution_manager.py +6 -0
  67. letta/services/tool_executor/tool_executor_base.py +3 -0
  68. letta/services/tool_sandbox/base.py +39 -1
  69. letta/services/tool_sandbox/e2b_sandbox.py +7 -0
  70. letta/services/user_manager.py +3 -2
  71. letta/settings.py +8 -14
  72. letta/system.py +17 -17
  73. letta/templates/sandbox_code_file_async.py.j2 +59 -0
  74. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/METADATA +3 -2
  75. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/RECORD +78 -76
  76. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/LICENSE +0 -0
  77. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/WHEEL +0 -0
  78. {letta_nightly-0.8.5.dev20250625104328.dist-info → letta_nightly-0.8.6.dev20250626104326.dist-info}/entry_points.txt +0 -0
@@ -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", "send_message_to_agent_async", "memory_finish_edits"}:
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", "send_message_to_agent_async", "memory_finish_edits"}:
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(agent_state.id, supplied_initial_message_sequence, agent_state.llm_config.model, actor)
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, actor=actor)
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
@@ -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, text: str, file_metadata: FileMetadata, start: Optional[int] = None, end: Optional[int] = None, add_metadata: bool = True
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
- text=visible_content,
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
- async with db_registry.async_session() as session:
39
- query = select(FileAgentModel).where(
40
- and_(
41
- FileAgentModel.agent_id == agent_id,
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
- now_ts = datetime.now(timezone.utc)
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
- if existing:
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 visible_content is not None and existing.visible_content != visible_content:
57
- existing.visible_content = visible_content
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
- existing.last_accessed_at = now_ts
72
+ if visible_content is not None and existing.visible_content != visible_content:
73
+ existing.visible_content = visible_content
60
74
 
61
- await existing.update_async(session, actor=actor)
62
- return existing.to_pydantic()
75
+ existing.last_accessed_at = now_ts
63
76
 
64
- assoc = FileAgentModel(
65
- agent_id=agent_id,
66
- file_id=file_id,
67
- file_name=file_name,
68
- organization_id=actor.organization_id,
69
- is_open=is_open,
70
- visible_content=visible_content,
71
- last_accessed_at=now_ts,
72
- )
73
- await assoc.create_async(session, actor=actor)
74
- return assoc.to_pydantic()
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
- return [r.to_pydantic() for r in rows]
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_(