letta-nightly 0.11.4.dev20250825104222__py3-none-any.whl → 0.11.5__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 (68) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +9 -3
  3. letta/agents/base_agent.py +2 -2
  4. letta/agents/letta_agent.py +56 -45
  5. letta/agents/voice_agent.py +2 -2
  6. letta/data_sources/redis_client.py +146 -1
  7. letta/errors.py +4 -0
  8. letta/functions/function_sets/files.py +2 -2
  9. letta/functions/mcp_client/types.py +30 -6
  10. letta/functions/schema_generator.py +46 -1
  11. letta/functions/schema_validator.py +17 -2
  12. letta/functions/types.py +1 -1
  13. letta/helpers/tool_execution_helper.py +0 -2
  14. letta/llm_api/anthropic_client.py +27 -5
  15. letta/llm_api/deepseek_client.py +97 -0
  16. letta/llm_api/groq_client.py +79 -0
  17. letta/llm_api/helpers.py +0 -1
  18. letta/llm_api/llm_api_tools.py +2 -113
  19. letta/llm_api/llm_client.py +21 -0
  20. letta/llm_api/llm_client_base.py +11 -9
  21. letta/llm_api/openai_client.py +3 -0
  22. letta/llm_api/xai_client.py +85 -0
  23. letta/prompts/prompt_generator.py +190 -0
  24. letta/schemas/agent_file.py +17 -2
  25. letta/schemas/file.py +24 -1
  26. letta/schemas/job.py +2 -0
  27. letta/schemas/letta_message.py +2 -0
  28. letta/schemas/letta_request.py +22 -0
  29. letta/schemas/message.py +10 -1
  30. letta/schemas/providers/bedrock.py +1 -0
  31. letta/server/rest_api/redis_stream_manager.py +300 -0
  32. letta/server/rest_api/routers/v1/agents.py +129 -7
  33. letta/server/rest_api/routers/v1/folders.py +15 -5
  34. letta/server/rest_api/routers/v1/runs.py +101 -11
  35. letta/server/rest_api/routers/v1/sources.py +21 -53
  36. letta/server/rest_api/routers/v1/telemetry.py +14 -4
  37. letta/server/rest_api/routers/v1/tools.py +2 -2
  38. letta/server/rest_api/streaming_response.py +3 -24
  39. letta/server/server.py +0 -1
  40. letta/services/agent_manager.py +2 -2
  41. letta/services/agent_serialization_manager.py +129 -32
  42. letta/services/file_manager.py +111 -6
  43. letta/services/file_processor/file_processor.py +5 -2
  44. letta/services/files_agents_manager.py +60 -0
  45. letta/services/helpers/agent_manager_helper.py +4 -205
  46. letta/services/helpers/tool_parser_helper.py +6 -3
  47. letta/services/mcp/base_client.py +7 -1
  48. letta/services/mcp/sse_client.py +7 -2
  49. letta/services/mcp/stdio_client.py +5 -0
  50. letta/services/mcp/streamable_http_client.py +11 -2
  51. letta/services/mcp_manager.py +31 -30
  52. letta/services/source_manager.py +26 -1
  53. letta/services/summarizer/summarizer.py +21 -10
  54. letta/services/tool_executor/files_tool_executor.py +13 -9
  55. letta/services/tool_executor/mcp_tool_executor.py +3 -0
  56. letta/services/tool_executor/tool_execution_manager.py +13 -0
  57. letta/services/tool_manager.py +43 -20
  58. letta/settings.py +1 -0
  59. letta/utils.py +37 -0
  60. {letta_nightly-0.11.4.dev20250825104222.dist-info → letta_nightly-0.11.5.dist-info}/METADATA +2 -2
  61. {letta_nightly-0.11.4.dev20250825104222.dist-info → letta_nightly-0.11.5.dist-info}/RECORD +64 -63
  62. letta/functions/mcp_client/__init__.py +0 -0
  63. letta/functions/mcp_client/base_client.py +0 -156
  64. letta/functions/mcp_client/sse_client.py +0 -51
  65. letta/functions/mcp_client/stdio_client.py +0 -109
  66. {letta_nightly-0.11.4.dev20250825104222.dist-info → letta_nightly-0.11.5.dist-info}/LICENSE +0 -0
  67. {letta_nightly-0.11.4.dev20250825104222.dist-info → letta_nightly-0.11.5.dist-info}/WHEEL +0 -0
  68. {letta_nightly-0.11.4.dev20250825104222.dist-info → letta_nightly-0.11.5.dist-info}/entry_points.txt +0 -0
letta/__init__.py CHANGED
@@ -5,7 +5,7 @@ try:
5
5
  __version__ = version("letta")
6
6
  except PackageNotFoundError:
7
7
  # Fallback for development installations
8
- __version__ = "0.11.4"
8
+ __version__ = "0.11.5"
9
9
 
10
10
  if os.environ.get("LETTA_VERSION"):
11
11
  __version__ = os.environ["LETTA_VERSION"]
letta/agent.py CHANGED
@@ -42,6 +42,7 @@ from letta.log import get_logger
42
42
  from letta.memory import summarize_messages
43
43
  from letta.orm import User
44
44
  from letta.otel.tracing import log_event, trace_method
45
+ from letta.prompts.prompt_generator import PromptGenerator
45
46
  from letta.schemas.agent import AgentState, AgentStepResponse, UpdateAgent, get_prompt_template_for_agent_type
46
47
  from letta.schemas.block import BlockUpdate
47
48
  from letta.schemas.embedding_config import EmbeddingConfig
@@ -59,7 +60,7 @@ from letta.schemas.tool_rule import TerminalToolRule
59
60
  from letta.schemas.usage import LettaUsageStatistics
60
61
  from letta.services.agent_manager import AgentManager
61
62
  from letta.services.block_manager import BlockManager
62
- from letta.services.helpers.agent_manager_helper import check_supports_structured_output, compile_memory_metadata_block
63
+ from letta.services.helpers.agent_manager_helper import check_supports_structured_output
63
64
  from letta.services.helpers.tool_parser_helper import runtime_override_tool_json_schema
64
65
  from letta.services.job_manager import JobManager
65
66
  from letta.services.mcp.base_client import AsyncBaseMCPClient
@@ -330,8 +331,13 @@ class Agent(BaseAgent):
330
331
  return None
331
332
 
332
333
  allowed_functions = [func for func in agent_state_tool_jsons if func["name"] in allowed_tool_names]
334
+ # Extract terminal tool names from tool rules
335
+ terminal_tool_names = {rule.tool_name for rule in self.tool_rules_solver.terminal_tool_rules}
333
336
  allowed_functions = runtime_override_tool_json_schema(
334
- tool_list=allowed_functions, response_format=self.agent_state.response_format, request_heartbeat=True
337
+ tool_list=allowed_functions,
338
+ response_format=self.agent_state.response_format,
339
+ request_heartbeat=True,
340
+ terminal_tools=terminal_tool_names,
335
341
  )
336
342
 
337
343
  # For the first message, force the initial tool if one is specified
@@ -1246,7 +1252,7 @@ class Agent(BaseAgent):
1246
1252
 
1247
1253
  agent_manager_passage_size = self.agent_manager.passage_size(actor=self.user, agent_id=self.agent_state.id)
1248
1254
  message_manager_size = self.message_manager.size(actor=self.user, agent_id=self.agent_state.id)
1249
- external_memory_summary = compile_memory_metadata_block(
1255
+ external_memory_summary = PromptGenerator.compile_memory_metadata_block(
1250
1256
  memory_edit_timestamp=get_utc_time(),
1251
1257
  timezone=self.agent_state.timezone,
1252
1258
  previous_message_count=self.message_manager.size(actor=self.user, agent_id=self.agent_state.id),
@@ -7,6 +7,7 @@ from letta.constants import DEFAULT_MAX_STEPS
7
7
  from letta.helpers import ToolRulesSolver
8
8
  from letta.helpers.datetime_helpers import get_utc_time
9
9
  from letta.log import get_logger
10
+ from letta.prompts.prompt_generator import PromptGenerator
10
11
  from letta.schemas.agent import AgentState
11
12
  from letta.schemas.enums import MessageStreamStatus
12
13
  from letta.schemas.letta_message import LegacyLettaMessage, LettaMessage
@@ -17,7 +18,6 @@ from letta.schemas.message import Message, MessageCreate, MessageUpdate
17
18
  from letta.schemas.usage import LettaUsageStatistics
18
19
  from letta.schemas.user import User
19
20
  from letta.services.agent_manager import AgentManager
20
- from letta.services.helpers.agent_manager_helper import get_system_message_from_compiled_memory
21
21
  from letta.services.message_manager import MessageManager
22
22
  from letta.services.passage_manager import PassageManager
23
23
  from letta.utils import united_diff
@@ -142,7 +142,7 @@ class BaseAgent(ABC):
142
142
  if num_archival_memories is None:
143
143
  num_archival_memories = await self.passage_manager.agent_passage_size_async(actor=self.actor, agent_id=agent_state.id)
144
144
 
145
- new_system_message_str = get_system_message_from_compiled_memory(
145
+ new_system_message_str = PromptGenerator.get_system_message_from_compiled_memory(
146
146
  system_prompt=agent_state.system,
147
147
  memory_with_sources=curr_memory_str,
148
148
  in_context_memory_last_edit=memory_edit_timestamp,
@@ -137,6 +137,10 @@ class LettaAgent(BaseAgent):
137
137
  message_buffer_limit=message_buffer_limit,
138
138
  message_buffer_min=message_buffer_min,
139
139
  partial_evict_summarizer_percentage=partial_evict_summarizer_percentage,
140
+ agent_manager=self.agent_manager,
141
+ message_manager=self.message_manager,
142
+ actor=self.actor,
143
+ agent_id=self.agent_id,
140
144
  )
141
145
 
142
146
  async def _check_run_cancellation(self) -> bool:
@@ -345,16 +349,17 @@ class LettaAgent(BaseAgent):
345
349
  agent_step_span.end()
346
350
 
347
351
  # Log LLM Trace
348
- await self.telemetry_manager.create_provider_trace_async(
349
- actor=self.actor,
350
- provider_trace_create=ProviderTraceCreate(
351
- request_json=request_data,
352
- response_json=response_data,
353
- step_id=step_id, # Use original step_id for telemetry
354
- organization_id=self.actor.organization_id,
355
- ),
356
- )
357
- step_progression = StepProgression.LOGGED_TRACE
352
+ if settings.track_provider_trace:
353
+ await self.telemetry_manager.create_provider_trace_async(
354
+ actor=self.actor,
355
+ provider_trace_create=ProviderTraceCreate(
356
+ request_json=request_data,
357
+ response_json=response_data,
358
+ step_id=step_id, # Use original step_id for telemetry
359
+ organization_id=self.actor.organization_id,
360
+ ),
361
+ )
362
+ step_progression = StepProgression.LOGGED_TRACE
358
363
 
359
364
  # stream step
360
365
  # TODO: improve TTFT
@@ -642,17 +647,18 @@ class LettaAgent(BaseAgent):
642
647
  agent_step_span.end()
643
648
 
644
649
  # Log LLM Trace
645
- await self.telemetry_manager.create_provider_trace_async(
646
- actor=self.actor,
647
- provider_trace_create=ProviderTraceCreate(
648
- request_json=request_data,
649
- response_json=response_data,
650
- step_id=step_id, # Use original step_id for telemetry
651
- organization_id=self.actor.organization_id,
652
- ),
653
- )
650
+ if settings.track_provider_trace:
651
+ await self.telemetry_manager.create_provider_trace_async(
652
+ actor=self.actor,
653
+ provider_trace_create=ProviderTraceCreate(
654
+ request_json=request_data,
655
+ response_json=response_data,
656
+ step_id=step_id, # Use original step_id for telemetry
657
+ organization_id=self.actor.organization_id,
658
+ ),
659
+ )
660
+ step_progression = StepProgression.LOGGED_TRACE
654
661
 
655
- step_progression = StepProgression.LOGGED_TRACE
656
662
  MetricRegistry().step_execution_time_ms_histogram.record(get_utc_timestamp_ns() - step_start, get_ctx_attributes())
657
663
  step_progression = StepProgression.FINISHED
658
664
 
@@ -1003,31 +1009,32 @@ class LettaAgent(BaseAgent):
1003
1009
  # Log LLM Trace
1004
1010
  # We are piecing together the streamed response here.
1005
1011
  # Content here does not match the actual response schema as streams come in chunks.
1006
- await self.telemetry_manager.create_provider_trace_async(
1007
- actor=self.actor,
1008
- provider_trace_create=ProviderTraceCreate(
1009
- request_json=request_data,
1010
- response_json={
1011
- "content": {
1012
- "tool_call": tool_call.model_dump_json(),
1013
- "reasoning": [content.model_dump_json() for content in reasoning_content],
1012
+ if settings.track_provider_trace:
1013
+ await self.telemetry_manager.create_provider_trace_async(
1014
+ actor=self.actor,
1015
+ provider_trace_create=ProviderTraceCreate(
1016
+ request_json=request_data,
1017
+ response_json={
1018
+ "content": {
1019
+ "tool_call": tool_call.model_dump_json(),
1020
+ "reasoning": [content.model_dump_json() for content in reasoning_content],
1021
+ },
1022
+ "id": interface.message_id,
1023
+ "model": interface.model,
1024
+ "role": "assistant",
1025
+ # "stop_reason": "",
1026
+ # "stop_sequence": None,
1027
+ "type": "message",
1028
+ "usage": {
1029
+ "input_tokens": usage.prompt_tokens,
1030
+ "output_tokens": usage.completion_tokens,
1031
+ },
1014
1032
  },
1015
- "id": interface.message_id,
1016
- "model": interface.model,
1017
- "role": "assistant",
1018
- # "stop_reason": "",
1019
- # "stop_sequence": None,
1020
- "type": "message",
1021
- "usage": {
1022
- "input_tokens": usage.prompt_tokens,
1023
- "output_tokens": usage.completion_tokens,
1024
- },
1025
- },
1026
- step_id=step_id, # Use original step_id for telemetry
1027
- organization_id=self.actor.organization_id,
1028
- ),
1029
- )
1030
- step_progression = StepProgression.LOGGED_TRACE
1033
+ step_id=step_id, # Use original step_id for telemetry
1034
+ organization_id=self.actor.organization_id,
1035
+ ),
1036
+ )
1037
+ step_progression = StepProgression.LOGGED_TRACE
1031
1038
 
1032
1039
  # yields tool response as this is handled from Letta and not the response from the LLM provider
1033
1040
  tool_return = [msg for msg in persisted_messages if msg.role == "tool"][-1].to_letta_messages()[0]
@@ -1352,6 +1359,7 @@ class LettaAgent(BaseAgent):
1352
1359
  ) -> list[Message]:
1353
1360
  # If total tokens is reached, we truncate down
1354
1361
  # TODO: This can be broken by bad configs, e.g. lower bound too high, initial messages too fat, etc.
1362
+ # TODO: `force` and `clear` seem to no longer be used, we should remove
1355
1363
  if force or (total_tokens and total_tokens > llm_config.context_window):
1356
1364
  self.logger.warning(
1357
1365
  f"Total tokens {total_tokens} exceeds configured max tokens {llm_config.context_window}, forcefully clearing message history."
@@ -1363,6 +1371,7 @@ class LettaAgent(BaseAgent):
1363
1371
  clear=True,
1364
1372
  )
1365
1373
  else:
1374
+ # NOTE (Sarah): Seems like this is doing nothing?
1366
1375
  self.logger.info(
1367
1376
  f"Total tokens {total_tokens} does not exceed configured max tokens {llm_config.context_window}, passing summarizing w/o force."
1368
1377
  )
@@ -1453,8 +1462,10 @@ class LettaAgent(BaseAgent):
1453
1462
  force_tool_call = valid_tool_names[0]
1454
1463
 
1455
1464
  allowed_tools = [enable_strict_mode(t.json_schema) for t in tools if t.name in set(valid_tool_names)]
1465
+ # Extract terminal tool names from tool rules
1466
+ terminal_tool_names = {rule.tool_name for rule in tool_rules_solver.terminal_tool_rules}
1456
1467
  allowed_tools = runtime_override_tool_json_schema(
1457
- tool_list=allowed_tools, response_format=agent_state.response_format, request_heartbeat=True
1468
+ tool_list=allowed_tools, response_format=agent_state.response_format, request_heartbeat=True, terminal_tools=terminal_tool_names
1458
1469
  )
1459
1470
 
1460
1471
  return (
@@ -13,6 +13,7 @@ from letta.helpers.datetime_helpers import get_utc_time
13
13
  from letta.helpers.tool_execution_helper import add_pre_execution_message, enable_strict_mode, remove_request_heartbeat
14
14
  from letta.interfaces.openai_chat_completions_streaming_interface import OpenAIChatCompletionsStreamingInterface
15
15
  from letta.log import get_logger
16
+ from letta.prompts.prompt_generator import PromptGenerator
16
17
  from letta.schemas.agent import AgentState, AgentType
17
18
  from letta.schemas.enums import MessageRole, ToolType
18
19
  from letta.schemas.letta_response import LettaResponse
@@ -35,7 +36,6 @@ from letta.server.rest_api.utils import (
35
36
  )
36
37
  from letta.services.agent_manager import AgentManager
37
38
  from letta.services.block_manager import BlockManager
38
- from letta.services.helpers.agent_manager_helper import compile_system_message_async
39
39
  from letta.services.job_manager import JobManager
40
40
  from letta.services.message_manager import MessageManager
41
41
  from letta.services.passage_manager import PassageManager
@@ -144,7 +144,7 @@ class VoiceAgent(BaseAgent):
144
144
 
145
145
  in_context_messages = await self.message_manager.get_messages_by_ids_async(message_ids=agent_state.message_ids, actor=self.actor)
146
146
  memory_edit_timestamp = get_utc_time()
147
- in_context_messages[0].content[0].text = await compile_system_message_async(
147
+ in_context_messages[0].content[0].text = await PromptGenerator.compile_system_message_async(
148
148
  system_prompt=agent_state.system,
149
149
  in_context_memory=agent_state.memory,
150
150
  in_context_memory_last_edit=memory_edit_timestamp,
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
  from functools import wraps
3
- from typing import Any, Optional, Set, Union
3
+ from typing import Any, Dict, List, Optional, Set, Union
4
4
 
5
5
  from letta.constants import REDIS_EXCLUDE, REDIS_INCLUDE, REDIS_SET_DEFAULT_VAL
6
6
  from letta.log import get_logger
@@ -218,6 +218,126 @@ class AsyncRedisClient:
218
218
  client = await self.get_client()
219
219
  return await client.decr(key)
220
220
 
221
+ # Stream operations
222
+ @with_retry()
223
+ async def xadd(self, stream: str, fields: Dict[str, Any], id: str = "*", maxlen: Optional[int] = None, approximate: bool = True) -> str:
224
+ """Add entry to a stream.
225
+
226
+ Args:
227
+ stream: Stream name
228
+ fields: Dict of field-value pairs to add
229
+ id: Entry ID ('*' for auto-generation)
230
+ maxlen: Maximum length of the stream
231
+ approximate: Whether maxlen is approximate
232
+
233
+ Returns:
234
+ The ID of the added entry
235
+ """
236
+ client = await self.get_client()
237
+ return await client.xadd(stream, fields, id=id, maxlen=maxlen, approximate=approximate)
238
+
239
+ @with_retry()
240
+ async def xread(self, streams: Dict[str, str], count: Optional[int] = None, block: Optional[int] = None) -> List[Dict]:
241
+ """Read from streams.
242
+
243
+ Args:
244
+ streams: Dict mapping stream names to IDs
245
+ count: Maximum number of entries to return
246
+ block: Milliseconds to block waiting for data (None = no blocking)
247
+
248
+ Returns:
249
+ List of entries from the streams
250
+ """
251
+ client = await self.get_client()
252
+ return await client.xread(streams, count=count, block=block)
253
+
254
+ @with_retry()
255
+ async def xrange(self, stream: str, start: str = "-", end: str = "+", count: Optional[int] = None) -> List[Dict]:
256
+ """Read range of entries from a stream.
257
+
258
+ Args:
259
+ stream: Stream name
260
+ start: Start ID (inclusive)
261
+ end: End ID (inclusive)
262
+ count: Maximum number of entries to return
263
+
264
+ Returns:
265
+ List of entries in the specified range
266
+ """
267
+ client = await self.get_client()
268
+ return await client.xrange(stream, start, end, count=count)
269
+
270
+ @with_retry()
271
+ async def xrevrange(self, stream: str, start: str = "+", end: str = "-", count: Optional[int] = None) -> List[Dict]:
272
+ """Read range of entries from a stream in reverse order.
273
+
274
+ Args:
275
+ stream: Stream name
276
+ start: Start ID (inclusive)
277
+ end: End ID (inclusive)
278
+ count: Maximum number of entries to return
279
+
280
+ Returns:
281
+ List of entries in the specified range in reverse order
282
+ """
283
+ client = await self.get_client()
284
+ return await client.xrevrange(stream, start, end, count=count)
285
+
286
+ @with_retry()
287
+ async def xlen(self, stream: str) -> int:
288
+ """Get the length of a stream.
289
+
290
+ Args:
291
+ stream: Stream name
292
+
293
+ Returns:
294
+ Number of entries in the stream
295
+ """
296
+ client = await self.get_client()
297
+ return await client.xlen(stream)
298
+
299
+ @with_retry()
300
+ async def xdel(self, stream: str, *ids: str) -> int:
301
+ """Delete entries from a stream.
302
+
303
+ Args:
304
+ stream: Stream name
305
+ ids: IDs of entries to delete
306
+
307
+ Returns:
308
+ Number of entries deleted
309
+ """
310
+ client = await self.get_client()
311
+ return await client.xdel(stream, *ids)
312
+
313
+ @with_retry()
314
+ async def xinfo_stream(self, stream: str) -> Dict:
315
+ """Get information about a stream.
316
+
317
+ Args:
318
+ stream: Stream name
319
+
320
+ Returns:
321
+ Dict with stream information
322
+ """
323
+ client = await self.get_client()
324
+ return await client.xinfo_stream(stream)
325
+
326
+ @with_retry()
327
+ async def xtrim(self, stream: str, maxlen: int, approximate: bool = True) -> int:
328
+ """Trim a stream to a maximum length.
329
+
330
+ Args:
331
+ stream: Stream name
332
+ maxlen: Maximum length
333
+ approximate: Whether maxlen is approximate
334
+
335
+ Returns:
336
+ Number of entries removed
337
+ """
338
+ client = await self.get_client()
339
+ return await client.xtrim(stream, maxlen=maxlen, approximate=approximate)
340
+
221
341
  async def check_inclusion_and_exclusion(self, member: str, group: str) -> bool:
222
342
  exclude_key = self._get_group_exclusion_key(group)
223
343
  include_key = self._get_group_inclusion_key(group)
@@ -290,6 +410,31 @@ class NoopAsyncRedisClient(AsyncRedisClient):
290
410
  async def srem(self, key: str, *members: Union[str, int, float]) -> int:
291
411
  return 0
292
412
 
413
+ # Stream operations
414
+ async def xadd(self, stream: str, fields: Dict[str, Any], id: str = "*", maxlen: Optional[int] = None, approximate: bool = True) -> str:
415
+ return ""
416
+
417
+ async def xread(self, streams: Dict[str, str], count: Optional[int] = None, block: Optional[int] = None) -> List[Dict]:
418
+ return []
419
+
420
+ async def xrange(self, stream: str, start: str = "-", end: str = "+", count: Optional[int] = None) -> List[Dict]:
421
+ return []
422
+
423
+ async def xrevrange(self, stream: str, start: str = "+", end: str = "-", count: Optional[int] = None) -> List[Dict]:
424
+ return []
425
+
426
+ async def xlen(self, stream: str) -> int:
427
+ return 0
428
+
429
+ async def xdel(self, stream: str, *ids: str) -> int:
430
+ return 0
431
+
432
+ async def xinfo_stream(self, stream: str) -> Dict:
433
+ return {}
434
+
435
+ async def xtrim(self, stream: str, maxlen: int, approximate: bool = True) -> int:
436
+ return 0
437
+
293
438
 
294
439
  async def get_redis_client() -> AsyncRedisClient:
295
440
  global _client_instance
letta/errors.py CHANGED
@@ -76,6 +76,10 @@ class LettaUserNotFoundError(LettaError):
76
76
  """Error raised when a user is not found."""
77
77
 
78
78
 
79
+ class LettaUnexpectedStreamCancellationError(LettaError):
80
+ """Error raised when a streaming request is terminated unexpectedly."""
81
+
82
+
79
83
  class LLMError(LettaError):
80
84
  pass
81
85
 
@@ -21,8 +21,8 @@ async def open_files(agent_state: "AgentState", file_requests: List[FileOpenRequ
21
21
 
22
22
  Open multiple files with different view ranges:
23
23
  file_requests = [
24
- FileOpenRequest(file_name="project_utils/config.py", offset=1, length=50), # Lines 1-50
25
- FileOpenRequest(file_name="project_utils/main.py", offset=100, length=100), # Lines 100-199
24
+ FileOpenRequest(file_name="project_utils/config.py", offset=0, length=50), # Lines 1-50
25
+ FileOpenRequest(file_name="project_utils/main.py", offset=100, length=100), # Lines 101-200
26
26
  FileOpenRequest(file_name="project_utils/utils.py") # Entire file
27
27
  ]
28
28
 
@@ -148,9 +148,21 @@ class SSEServerConfig(BaseServerConfig):
148
148
  custom_headers: Optional[dict[str, str]] = Field(None, description="Custom HTTP headers to include with SSE requests")
149
149
 
150
150
  def resolve_token(self) -> Optional[str]:
151
- if self.auth_token and self.auth_token.startswith(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} "):
152
- return self.auth_token[len(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} ") :]
153
- return self.auth_token
151
+ """
152
+ Extract token for storage if auth_header/auth_token are provided
153
+ and not already in custom_headers.
154
+
155
+ Returns:
156
+ The resolved token (without Bearer prefix) if it should be stored separately, None otherwise
157
+ """
158
+ if self.auth_token and self.auth_header:
159
+ # Check if custom_headers already has the auth header
160
+ if not self.custom_headers or self.auth_header not in self.custom_headers:
161
+ # Strip Bearer prefix if present
162
+ if self.auth_token.startswith(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} "):
163
+ return self.auth_token[len(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} ") :]
164
+ return self.auth_token
165
+ return None
154
166
 
155
167
  def resolve_environment_variables(self, environment_variables: Optional[Dict[str, str]] = None) -> None:
156
168
  if self.auth_token and super().is_templated_tool_variable(self.auth_token):
@@ -217,9 +229,21 @@ class StreamableHTTPServerConfig(BaseServerConfig):
217
229
  custom_headers: Optional[dict[str, str]] = Field(None, description="Custom HTTP headers to include with streamable HTTP requests")
218
230
 
219
231
  def resolve_token(self) -> Optional[str]:
220
- if self.auth_token and self.auth_token.startswith(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} "):
221
- return self.auth_token[len(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} ") :]
222
- return self.auth_token
232
+ """
233
+ Extract token for storage if auth_header/auth_token are provided
234
+ and not already in custom_headers.
235
+
236
+ Returns:
237
+ The resolved token (without Bearer prefix) if it should be stored separately, None otherwise
238
+ """
239
+ if self.auth_token and self.auth_header:
240
+ # Check if custom_headers already has the auth header
241
+ if not self.custom_headers or self.auth_header not in self.custom_headers:
242
+ # Strip Bearer prefix if present
243
+ if self.auth_token.startswith(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} "):
244
+ return self.auth_token[len(f"{MCP_AUTH_TOKEN_BEARER_PREFIX} ") :]
245
+ return self.auth_token
246
+ return None
223
247
 
224
248
  def resolve_environment_variables(self, environment_variables: Optional[Dict[str, str]] = None) -> None:
225
249
  if self.auth_token and super().is_templated_tool_variable(self.auth_token):
@@ -608,13 +608,58 @@ def generate_tool_schema_for_mcp(
608
608
  # Normalise so downstream code can treat it consistently.
609
609
  parameters_schema.setdefault("required", [])
610
610
 
611
+ # Process properties to handle anyOf types and make optional fields strict-compatible
612
+ if "properties" in parameters_schema:
613
+ for field_name, field_props in parameters_schema["properties"].items():
614
+ # Handle anyOf types by flattening to type array
615
+ if "anyOf" in field_props and "type" not in field_props:
616
+ types = []
617
+ format_value = None
618
+ for option in field_props["anyOf"]:
619
+ if "type" in option:
620
+ types.append(option["type"])
621
+ # Capture format if present (e.g., uuid format for strings)
622
+ if "format" in option and not format_value:
623
+ format_value = option["format"]
624
+ if types:
625
+ # Deduplicate types using set
626
+ field_props["type"] = list(set(types))
627
+ # Only add format if the field is not optional (doesn't have null type)
628
+ if format_value and len(field_props["type"]) == 1 and "null" not in field_props["type"]:
629
+ field_props["format"] = format_value
630
+ # Remove the anyOf since we've flattened it
631
+ del field_props["anyOf"]
632
+
633
+ # For strict mode: heal optional fields by making them required with null type
634
+ if strict and field_name not in parameters_schema["required"]:
635
+ # Field is optional - add it to required array
636
+ parameters_schema["required"].append(field_name)
637
+
638
+ # Ensure the field can accept null to maintain optionality
639
+ if "type" in field_props:
640
+ if isinstance(field_props["type"], list):
641
+ # Already an array of types - add null if not present
642
+ if "null" not in field_props["type"]:
643
+ field_props["type"].append("null")
644
+ # Deduplicate
645
+ field_props["type"] = list(set(field_props["type"]))
646
+ elif field_props["type"] != "null":
647
+ # Single type - convert to array with null
648
+ field_props["type"] = list(set([field_props["type"], "null"]))
649
+ elif "anyOf" in field_props:
650
+ # If there's still an anyOf, ensure null is one of the options
651
+ has_null = any(opt.get("type") == "null" for opt in field_props["anyOf"])
652
+ if not has_null:
653
+ field_props["anyOf"].append({"type": "null"})
654
+
611
655
  # Add the optional heartbeat parameter
612
656
  if append_heartbeat:
613
657
  parameters_schema["properties"][REQUEST_HEARTBEAT_PARAM] = {
614
658
  "type": "boolean",
615
659
  "description": REQUEST_HEARTBEAT_DESCRIPTION,
616
660
  }
617
- parameters_schema["required"].append(REQUEST_HEARTBEAT_PARAM)
661
+ if REQUEST_HEARTBEAT_PARAM not in parameters_schema["required"]:
662
+ parameters_schema["required"].append(REQUEST_HEARTBEAT_PARAM)
618
663
 
619
664
  # Return the final schema
620
665
  if strict:
@@ -116,15 +116,21 @@ def validate_complete_json_schema(schema: Dict[str, Any]) -> Tuple[SchemaHealth,
116
116
 
117
117
  required = node.get("required")
118
118
  if required is None:
119
+ # TODO: @jnjpng skip this check for now, seems like OpenAI strict mode doesn't enforce this
119
120
  # Only mark as non-strict for nested objects, not root
120
- if not is_root:
121
- mark_non_strict(f"{path}: 'required' not specified for object")
121
+ # if not is_root:
122
+ # mark_non_strict(f"{path}: 'required' not specified for object")
122
123
  required = []
123
124
  elif not isinstance(required, list):
124
125
  mark_invalid(f"{path}: 'required' must be a list if present")
125
126
  required = []
126
127
 
127
128
  # OpenAI strict-mode extra checks:
129
+ # NOTE: We no longer flag properties not in required array as non-strict
130
+ # because we can heal these schemas by adding null to the type union
131
+ # This allows MCP tools with optional fields to be used with strict mode
132
+ # The healing happens in generate_tool_schema_for_mcp() when strict=True
133
+
128
134
  for req_key in required:
129
135
  if props and req_key not in props:
130
136
  mark_invalid(f"{path}: required contains '{req_key}' not found in properties")
@@ -161,6 +167,15 @@ def validate_complete_json_schema(schema: Dict[str, Any]) -> Tuple[SchemaHealth,
161
167
  # These are generally fine, but check for specific constraints
162
168
  pass
163
169
 
170
+ # TYPE ARRAYS (e.g., ["string", "null"] for optional fields)
171
+ elif isinstance(node_type, list):
172
+ # Type arrays are allowed in OpenAI strict mode
173
+ # They represent union types (e.g., string | null)
174
+ for t in node_type:
175
+ # TODO: @jnjpng handle enum types?
176
+ if t not in ["string", "number", "integer", "boolean", "null", "array", "object"]:
177
+ mark_invalid(f"{path}: Invalid type '{t}' in type array")
178
+
164
179
  # UNION TYPES
165
180
  for kw in ("anyOf", "oneOf", "allOf"):
166
181
  if kw in node:
letta/functions/types.py CHANGED
@@ -11,7 +11,7 @@ class SearchTask(BaseModel):
11
11
  class FileOpenRequest(BaseModel):
12
12
  file_name: str = Field(description="Name of the file to open")
13
13
  offset: Optional[int] = Field(
14
- default=None, description="Optional starting line number (1-indexed). If not specified, starts from beginning of file."
14
+ default=None, description="Optional offset for starting line number (0-indexed). If not specified, starts from beginning of file."
15
15
  )
16
16
  length: Optional[int] = Field(
17
17
  default=None, description="Optional number of lines to view from offset (inclusive). If not specified, views to end of file."
@@ -39,12 +39,10 @@ def enable_strict_mode(tool_schema: Dict[str, Any]) -> Dict[str, Any]:
39
39
 
40
40
  # Ensure parameters is a valid dictionary
41
41
  parameters = schema.get("parameters", {})
42
-
43
42
  if isinstance(parameters, dict) and parameters.get("type") == "object":
44
43
  # Set additionalProperties to False
45
44
  parameters["additionalProperties"] = False
46
45
  schema["parameters"] = parameters
47
-
48
46
  # Remove the metadata fields from the schema
49
47
  schema.pop(MCP_TOOL_METADATA_SCHEMA_STATUS, None)
50
48
  schema.pop(MCP_TOOL_METADATA_SCHEMA_WARNINGS, None)