letta-nightly 0.13.0.dev20251031104146__py3-none-any.whl → 0.13.1.dev20251101010313__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.

Potentially problematic release.


This version of letta-nightly might be problematic. Click here for more details.

Files changed (105) hide show
  1. letta/__init__.py +1 -1
  2. letta/adapters/simple_llm_stream_adapter.py +1 -0
  3. letta/agents/letta_agent_v2.py +8 -0
  4. letta/agents/letta_agent_v3.py +127 -27
  5. letta/agents/temporal/activities/__init__.py +25 -0
  6. letta/agents/temporal/activities/create_messages.py +26 -0
  7. letta/agents/temporal/activities/create_step.py +57 -0
  8. letta/agents/temporal/activities/example_activity.py +9 -0
  9. letta/agents/temporal/activities/execute_tool.py +130 -0
  10. letta/agents/temporal/activities/llm_request.py +114 -0
  11. letta/agents/temporal/activities/prepare_messages.py +27 -0
  12. letta/agents/temporal/activities/refresh_context.py +160 -0
  13. letta/agents/temporal/activities/summarize_conversation_history.py +77 -0
  14. letta/agents/temporal/activities/update_message_ids.py +25 -0
  15. letta/agents/temporal/activities/update_run.py +43 -0
  16. letta/agents/temporal/constants.py +59 -0
  17. letta/agents/temporal/temporal_agent_workflow.py +704 -0
  18. letta/agents/temporal/types.py +275 -0
  19. letta/constants.py +11 -0
  20. letta/errors.py +4 -0
  21. letta/functions/function_sets/base.py +0 -11
  22. letta/groups/helpers.py +7 -1
  23. letta/groups/sleeptime_multi_agent_v4.py +4 -3
  24. letta/interfaces/anthropic_streaming_interface.py +0 -1
  25. letta/interfaces/openai_streaming_interface.py +103 -100
  26. letta/llm_api/anthropic_client.py +57 -12
  27. letta/llm_api/bedrock_client.py +1 -0
  28. letta/llm_api/deepseek_client.py +3 -2
  29. letta/llm_api/google_vertex_client.py +5 -4
  30. letta/llm_api/groq_client.py +1 -0
  31. letta/llm_api/llm_client_base.py +15 -1
  32. letta/llm_api/openai.py +2 -2
  33. letta/llm_api/openai_client.py +17 -3
  34. letta/llm_api/xai_client.py +1 -0
  35. letta/orm/agent.py +3 -0
  36. letta/orm/organization.py +4 -0
  37. letta/orm/sqlalchemy_base.py +7 -0
  38. letta/otel/tracing.py +131 -4
  39. letta/schemas/agent.py +108 -40
  40. letta/schemas/agent_file.py +10 -10
  41. letta/schemas/block.py +22 -3
  42. letta/schemas/enums.py +21 -0
  43. letta/schemas/environment_variables.py +3 -2
  44. letta/schemas/group.py +3 -3
  45. letta/schemas/letta_response.py +36 -4
  46. letta/schemas/llm_batch_job.py +3 -3
  47. letta/schemas/llm_config.py +123 -4
  48. letta/schemas/mcp.py +3 -2
  49. letta/schemas/mcp_server.py +3 -2
  50. letta/schemas/message.py +167 -49
  51. letta/schemas/model.py +265 -0
  52. letta/schemas/organization.py +2 -1
  53. letta/schemas/passage.py +2 -1
  54. letta/schemas/provider_trace.py +2 -1
  55. letta/schemas/providers/openrouter.py +1 -2
  56. letta/schemas/run_metrics.py +2 -1
  57. letta/schemas/sandbox_config.py +3 -1
  58. letta/schemas/step_metrics.py +2 -1
  59. letta/schemas/tool_rule.py +2 -2
  60. letta/schemas/user.py +2 -1
  61. letta/server/rest_api/app.py +5 -1
  62. letta/server/rest_api/routers/v1/__init__.py +4 -0
  63. letta/server/rest_api/routers/v1/agents.py +71 -9
  64. letta/server/rest_api/routers/v1/blocks.py +7 -7
  65. letta/server/rest_api/routers/v1/groups.py +40 -0
  66. letta/server/rest_api/routers/v1/identities.py +2 -2
  67. letta/server/rest_api/routers/v1/internal_agents.py +31 -0
  68. letta/server/rest_api/routers/v1/internal_blocks.py +177 -0
  69. letta/server/rest_api/routers/v1/internal_runs.py +25 -1
  70. letta/server/rest_api/routers/v1/runs.py +2 -22
  71. letta/server/rest_api/routers/v1/tools.py +12 -1
  72. letta/server/server.py +20 -4
  73. letta/services/agent_manager.py +4 -4
  74. letta/services/archive_manager.py +16 -0
  75. letta/services/group_manager.py +44 -0
  76. letta/services/helpers/run_manager_helper.py +2 -2
  77. letta/services/lettuce/lettuce_client.py +148 -0
  78. letta/services/mcp/base_client.py +9 -3
  79. letta/services/run_manager.py +148 -37
  80. letta/services/source_manager.py +91 -3
  81. letta/services/step_manager.py +2 -3
  82. letta/services/streaming_service.py +52 -13
  83. letta/services/summarizer/summarizer.py +28 -2
  84. letta/services/tool_executor/builtin_tool_executor.py +1 -1
  85. letta/services/tool_executor/core_tool_executor.py +2 -117
  86. letta/services/tool_sandbox/e2b_sandbox.py +4 -1
  87. letta/services/tool_schema_generator.py +2 -2
  88. letta/validators.py +21 -0
  89. {letta_nightly-0.13.0.dev20251031104146.dist-info → letta_nightly-0.13.1.dev20251101010313.dist-info}/METADATA +1 -1
  90. {letta_nightly-0.13.0.dev20251031104146.dist-info → letta_nightly-0.13.1.dev20251101010313.dist-info}/RECORD +93 -87
  91. letta/agent.py +0 -1758
  92. letta/cli/cli_load.py +0 -16
  93. letta/client/__init__.py +0 -0
  94. letta/client/streaming.py +0 -95
  95. letta/client/utils.py +0 -78
  96. letta/functions/async_composio_toolset.py +0 -109
  97. letta/functions/composio_helpers.py +0 -96
  98. letta/helpers/composio_helpers.py +0 -38
  99. letta/orm/job_messages.py +0 -33
  100. letta/schemas/providers.py +0 -1617
  101. letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +0 -132
  102. letta/services/tool_executor/composio_tool_executor.py +0 -57
  103. {letta_nightly-0.13.0.dev20251031104146.dist-info → letta_nightly-0.13.1.dev20251101010313.dist-info}/WHEEL +0 -0
  104. {letta_nightly-0.13.0.dev20251031104146.dist-info → letta_nightly-0.13.1.dev20251101010313.dist-info}/entry_points.txt +0 -0
  105. {letta_nightly-0.13.0.dev20251031104146.dist-info → letta_nightly-0.13.1.dev20251101010313.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,15 @@
1
- from typing import TYPE_CHECKING, Literal, Optional
1
+ from typing import TYPE_CHECKING, Annotated, Literal, Optional, Union
2
2
 
3
3
  from pydantic import BaseModel, ConfigDict, Field, model_validator
4
4
 
5
5
  from letta.constants import LETTA_MODEL_ENDPOINT
6
+ from letta.errors import LettaInvalidArgumentError
6
7
  from letta.log import get_logger
7
8
  from letta.schemas.enums import AgentType, ProviderCategory
8
9
 
10
+ if TYPE_CHECKING:
11
+ from letta.schemas.model import ModelSettings
12
+
9
13
  logger = get_logger(__name__)
10
14
 
11
15
 
@@ -163,6 +167,24 @@ class LLMConfig(BaseModel):
163
167
 
164
168
  return values
165
169
 
170
+ @model_validator(mode="before")
171
+ @classmethod
172
+ def validate_codex_reasoning_effort(cls, values):
173
+ """
174
+ Validate that gpt-5-codex models do not use 'minimal' reasoning effort.
175
+ Codex models require at least 'low' reasoning effort.
176
+ """
177
+ from letta.llm_api.openai_client import does_not_support_minimal_reasoning
178
+
179
+ model = values.get("model")
180
+ reasoning_effort = values.get("reasoning_effort")
181
+
182
+ if model and does_not_support_minimal_reasoning(model) and reasoning_effort == "minimal":
183
+ raise LettaInvalidArgumentError(
184
+ f"Model '{model}' does not support 'minimal' reasoning effort. Please use 'low', 'medium', or 'high' instead."
185
+ )
186
+ return values
187
+
166
188
  @classmethod
167
189
  def default_config(cls, model_name: str):
168
190
  """
@@ -233,6 +255,98 @@ class LLMConfig(BaseModel):
233
255
  + (f" [ip={self.model_endpoint}]" if self.model_endpoint else "")
234
256
  )
235
257
 
258
+ def _to_model(self) -> "ModelSettings":
259
+ """
260
+ Convert LLMConfig back into a Model schema (OpenAIModelSettings, AnthropicModelSettings, etc.).
261
+ This is the inverse of the _to_legacy_config_params() methods in model.py.
262
+ """
263
+ from letta.schemas.model import (
264
+ AnthropicModelSettings,
265
+ AnthropicThinking,
266
+ AzureModelSettings,
267
+ BedrockModelSettings,
268
+ DeepseekModelSettings,
269
+ GeminiThinkingConfig,
270
+ GoogleAIModelSettings,
271
+ GoogleVertexModelSettings,
272
+ GroqModelSettings,
273
+ Model,
274
+ OpenAIModelSettings,
275
+ OpenAIReasoning,
276
+ TogetherModelSettings,
277
+ XAIModelSettings,
278
+ )
279
+
280
+ if self.model_endpoint_type == "openai":
281
+ return OpenAIModelSettings(
282
+ model=self.model,
283
+ max_output_tokens=self.max_tokens or 4096,
284
+ temperature=self.temperature,
285
+ reasoning=OpenAIReasoning(reasoning_effort=self.reasoning_effort or "minimal"),
286
+ )
287
+ elif self.model_endpoint_type == "anthropic":
288
+ thinking_type = "enabled" if self.enable_reasoner else "disabled"
289
+ return AnthropicModelSettings(
290
+ model=self.model,
291
+ max_output_tokens=self.max_tokens or 4096,
292
+ temperature=self.temperature,
293
+ thinking=AnthropicThinking(type=thinking_type, budget_tokens=self.max_reasoning_tokens or 1024),
294
+ verbosity=self.verbosity,
295
+ )
296
+ elif self.model_endpoint_type == "google_ai":
297
+ return GoogleAIModelSettings(
298
+ model=self.model,
299
+ max_output_tokens=self.max_tokens or 65536,
300
+ temperature=self.temperature,
301
+ thinking_config=GeminiThinkingConfig(
302
+ include_thoughts=self.max_reasoning_tokens > 0, thinking_budget=self.max_reasoning_tokens or 1024
303
+ ),
304
+ )
305
+ elif self.model_endpoint_type == "google_vertex":
306
+ return GoogleVertexModelSettings(
307
+ model=self.model,
308
+ max_output_tokens=self.max_tokens or 65536,
309
+ temperature=self.temperature,
310
+ thinking_config=GeminiThinkingConfig(
311
+ include_thoughts=self.max_reasoning_tokens > 0, thinking_budget=self.max_reasoning_tokens or 1024
312
+ ),
313
+ )
314
+ elif self.model_endpoint_type == "azure":
315
+ return AzureModelSettings(
316
+ model=self.model,
317
+ max_output_tokens=self.max_tokens or 4096,
318
+ temperature=self.temperature,
319
+ )
320
+ elif self.model_endpoint_type == "xai":
321
+ return XAIModelSettings(
322
+ model=self.model,
323
+ max_output_tokens=self.max_tokens or 4096,
324
+ temperature=self.temperature,
325
+ )
326
+ elif self.model_endpoint_type == "groq":
327
+ return GroqModelSettings(
328
+ model=self.model,
329
+ max_output_tokens=self.max_tokens or 4096,
330
+ temperature=self.temperature,
331
+ )
332
+ elif self.model_endpoint_type == "deepseek":
333
+ return DeepseekModelSettings(
334
+ model=self.model,
335
+ max_output_tokens=self.max_tokens or 4096,
336
+ temperature=self.temperature,
337
+ )
338
+ elif self.model_endpoint_type == "together":
339
+ return TogetherModelSettings(
340
+ model=self.model,
341
+ max_output_tokens=self.max_tokens or 4096,
342
+ temperature=self.temperature,
343
+ )
344
+ elif self.model_endpoint_type == "bedrock":
345
+ return Model(model=self.model, max_output_tokens=self.max_tokens or 4096)
346
+ else:
347
+ # If we don't know the model type, use the default Model schema
348
+ return Model(model=self.model, max_output_tokens=self.max_tokens or 4096)
349
+
236
350
  @classmethod
237
351
  def is_openai_reasoning_model(cls, config: "LLMConfig") -> bool:
238
352
  from letta.llm_api.openai_client import is_openai_reasoning_model
@@ -277,6 +391,8 @@ class LLMConfig(BaseModel):
277
391
  - Google Gemini (2.5 family): force disabled until native reasoning supported
278
392
  - All others: disabled (no simulated reasoning via kwargs)
279
393
  """
394
+ from letta.llm_api.openai_client import does_not_support_minimal_reasoning
395
+
280
396
  # V1 agent policy: do not allow simulated reasoning for non-native models
281
397
  if agent_type is not None and agent_type == AgentType.letta_v1_agent:
282
398
  # OpenAI native reasoning models: always on
@@ -284,7 +400,8 @@ class LLMConfig(BaseModel):
284
400
  config.put_inner_thoughts_in_kwargs = False
285
401
  config.enable_reasoner = True
286
402
  if config.reasoning_effort is None:
287
- if config.model.startswith("gpt-5"):
403
+ # Codex models cannot use "minimal" reasoning effort
404
+ if config.model.startswith("gpt-5") and not does_not_support_minimal_reasoning(config.model):
288
405
  config.reasoning_effort = "minimal"
289
406
  else:
290
407
  config.reasoning_effort = "medium"
@@ -324,7 +441,8 @@ class LLMConfig(BaseModel):
324
441
  config.enable_reasoner = True
325
442
  if config.reasoning_effort is None:
326
443
  # GPT-5 models default to minimal, others to medium
327
- if config.model.startswith("gpt-5"):
444
+ # Codex models cannot use "minimal" reasoning effort
445
+ if config.model.startswith("gpt-5") and not does_not_support_minimal_reasoning(config.model):
328
446
  config.reasoning_effort = "minimal"
329
447
  else:
330
448
  config.reasoning_effort = "medium"
@@ -357,7 +475,8 @@ class LLMConfig(BaseModel):
357
475
  config.put_inner_thoughts_in_kwargs = False
358
476
  if config.reasoning_effort is None:
359
477
  # GPT-5 models default to minimal, others to medium
360
- if config.model.startswith("gpt-5"):
478
+ # Codex models cannot use "minimal" reasoning effort
479
+ if config.model.startswith("gpt-5") and not does_not_support_minimal_reasoning(config.model):
361
480
  config.reasoning_effort = "minimal"
362
481
  else:
363
482
  config.reasoning_effort = "medium"
letta/schemas/mcp.py CHANGED
@@ -13,13 +13,14 @@ from letta.functions.mcp_client.types import (
13
13
  StreamableHTTPServerConfig,
14
14
  )
15
15
  from letta.orm.mcp_oauth import OAuthSessionStatus
16
+ from letta.schemas.enums import PrimitiveType
16
17
  from letta.schemas.letta_base import LettaBase
17
18
  from letta.schemas.secret import Secret
18
19
  from letta.settings import settings
19
20
 
20
21
 
21
22
  class BaseMCPServer(LettaBase):
22
- __id_prefix__ = "mcp_server"
23
+ __id_prefix__ = PrimitiveType.MCP_SERVER.value
23
24
 
24
25
 
25
26
  class MCPServer(BaseMCPServer):
@@ -178,7 +179,7 @@ UpdateMCPServer = Union[UpdateSSEMCPServer, UpdateStdioMCPServer, UpdateStreamab
178
179
 
179
180
  # OAuth-related schemas
180
181
  class BaseMCPOAuth(LettaBase):
181
- __id_prefix__ = "mcp-oauth"
182
+ __id_prefix__ = PrimitiveType.MCP_OAUTH.value
182
183
 
183
184
 
184
185
  class MCPOAuthSession(BaseMCPOAuth):
@@ -13,12 +13,13 @@ from letta.functions.mcp_client.types import (
13
13
  StreamableHTTPServerConfig,
14
14
  )
15
15
  from letta.orm.mcp_oauth import OAuthSessionStatus
16
+ from letta.schemas.enums import PrimitiveType
16
17
  from letta.schemas.letta_base import LettaBase
17
18
  from letta.schemas.secret import Secret
18
19
 
19
20
 
20
21
  class BaseMCPServer(LettaBase):
21
- __id_prefix__ = "mcp_server"
22
+ __id_prefix__ = PrimitiveType.MCP_SERVER.value
22
23
 
23
24
 
24
25
  # Create Schemas (for POST requests)
@@ -101,7 +102,7 @@ UpdateMCPServerUnion = Union[UpdateStdioMCPServer, UpdateSSEMCPServer, UpdateStr
101
102
 
102
103
  # OAuth-related schemas
103
104
  class BaseMCPOAuth(LettaBase):
104
- __id_prefix__ = "mcp-oauth"
105
+ __id_prefix__ = PrimitiveType.MCP_OAUTH.value
105
106
 
106
107
 
107
108
  class MCPOAuthSession(BaseMCPOAuth):
letta/schemas/message.py CHANGED
@@ -13,7 +13,6 @@ from datetime import datetime, timezone
13
13
  from enum import Enum
14
14
  from typing import Annotated, Any, Dict, List, Literal, Optional, Union
15
15
 
16
- from letta_client import LettaMessageUnion
17
16
  from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall, Function as OpenAIFunction
18
17
  from openai.types.responses import ResponseReasoningItem
19
18
  from pydantic import BaseModel, Field, field_validator, model_validator
@@ -57,6 +56,14 @@ from letta.system import unpack_message
57
56
  from letta.utils import parse_json, validate_function_response
58
57
 
59
58
 
59
+ def truncate_tool_return(content: Optional[str], limit: Optional[int]) -> Optional[str]:
60
+ if limit is None or content is None:
61
+ return content
62
+ if len(content) <= limit:
63
+ return content
64
+ return content[:limit] + f"... [truncated {len(content) - limit} chars]"
65
+
66
+
60
67
  def add_inner_thoughts_to_tool_call(
61
68
  tool_call: OpenAIToolCall,
62
69
  inner_thoughts: str,
@@ -1091,6 +1098,7 @@ class Message(BaseMessage):
1091
1098
  # if true, then treat the content field as AssistantMessage
1092
1099
  native_content: bool = False,
1093
1100
  strip_request_heartbeat: bool = False,
1101
+ tool_return_truncation_chars: Optional[int] = None,
1094
1102
  ) -> dict | None:
1095
1103
  """Go from Message class to ChatCompletion message object"""
1096
1104
  assert not (native_content and put_inner_thoughts_in_kwargs), "native_content and put_inner_thoughts_in_kwargs cannot both be true"
@@ -1139,8 +1147,14 @@ class Message(BaseMessage):
1139
1147
  assert self.tool_calls is not None or text_content is not None, vars(self)
1140
1148
  except AssertionError as e:
1141
1149
  # relax check if this message only contains reasoning content
1142
- if self.content is not None and len(self.content) > 0 and isinstance(self.content[0], ReasoningContent):
1143
- return None
1150
+ if self.content is not None and len(self.content) > 0:
1151
+ # Check if all non-empty content is reasoning-related
1152
+ all_reasoning = all(
1153
+ isinstance(c, (ReasoningContent, SummarizedReasoningContent, OmittedReasoningContent, RedactedReasoningContent))
1154
+ for c in self.content
1155
+ )
1156
+ if all_reasoning:
1157
+ return None
1144
1158
  raise e
1145
1159
 
1146
1160
  # if native content, then put it directly inside the content
@@ -1181,12 +1195,26 @@ class Message(BaseMessage):
1181
1195
  tool_call_dict["id"] = tool_call_dict["id"][:max_tool_id_length]
1182
1196
 
1183
1197
  elif self.role == "tool":
1184
- assert self.tool_call_id is not None, vars(self)
1185
- openai_message = {
1186
- "content": text_content,
1187
- "role": self.role,
1188
- "tool_call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1189
- }
1198
+ # Handle tool returns - if tool_returns exists, use the first one
1199
+ if self.tool_returns and len(self.tool_returns) > 0:
1200
+ tool_return = self.tool_returns[0]
1201
+ if not tool_return.tool_call_id:
1202
+ raise TypeError("OpenAI API requires tool_call_id to be set.")
1203
+ func_response = truncate_tool_return(tool_return.func_response, tool_return_truncation_chars)
1204
+ openai_message = {
1205
+ "content": func_response,
1206
+ "role": self.role,
1207
+ "tool_call_id": tool_return.tool_call_id[:max_tool_id_length] if max_tool_id_length else tool_return.tool_call_id,
1208
+ }
1209
+ else:
1210
+ # Legacy fallback for old message format
1211
+ assert self.tool_call_id is not None, vars(self)
1212
+ legacy_content = truncate_tool_return(text_content, tool_return_truncation_chars)
1213
+ openai_message = {
1214
+ "content": legacy_content,
1215
+ "role": self.role,
1216
+ "tool_call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1217
+ }
1190
1218
 
1191
1219
  else:
1192
1220
  raise ValueError(self.role)
@@ -1215,22 +1243,42 @@ class Message(BaseMessage):
1215
1243
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1216
1244
  put_inner_thoughts_in_kwargs: bool = False,
1217
1245
  use_developer_message: bool = False,
1246
+ tool_return_truncation_chars: Optional[int] = None,
1218
1247
  ) -> List[dict]:
1219
1248
  messages = Message.filter_messages_for_llm_api(messages)
1220
- result = [
1221
- m.to_openai_dict(
1249
+ result: List[dict] = []
1250
+
1251
+ for m in messages:
1252
+ # Special case: OpenAI Chat Completions requires a separate tool message per tool_call_id
1253
+ # If we have multiple explicit tool_returns on a single Message, expand into one dict per return
1254
+ if m.role == MessageRole.tool and m.tool_returns and len(m.tool_returns) > 0:
1255
+ for tr in m.tool_returns:
1256
+ if not tr.tool_call_id:
1257
+ raise TypeError("ToolReturn came back without a tool_call_id.")
1258
+ result.append(
1259
+ {
1260
+ "content": tr.func_response,
1261
+ "role": "tool",
1262
+ "tool_call_id": tr.tool_call_id[:max_tool_id_length] if max_tool_id_length else tr.tool_call_id,
1263
+ }
1264
+ )
1265
+ continue
1266
+
1267
+ d = m.to_openai_dict(
1222
1268
  max_tool_id_length=max_tool_id_length,
1223
1269
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1224
1270
  use_developer_message=use_developer_message,
1271
+ tool_return_truncation_chars=tool_return_truncation_chars,
1225
1272
  )
1226
- for m in messages
1227
- ]
1228
- result = [m for m in result if m is not None]
1273
+ if d is not None:
1274
+ result.append(d)
1275
+
1229
1276
  return result
1230
1277
 
1231
1278
  def to_openai_responses_dicts(
1232
1279
  self,
1233
1280
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1281
+ tool_return_truncation_chars: Optional[int] = None,
1234
1282
  ) -> List[dict]:
1235
1283
  """Go from Message class to ChatCompletion message object"""
1236
1284
 
@@ -1306,15 +1354,31 @@ class Message(BaseMessage):
1306
1354
  )
1307
1355
 
1308
1356
  elif self.role == "tool":
1309
- assert self.tool_call_id is not None, vars(self)
1310
- assert len(self.content) == 1 and isinstance(self.content[0], TextContent), vars(self)
1311
- message_dicts.append(
1312
- {
1313
- "type": "function_call_output",
1314
- "call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1315
- "output": self.content[0].text,
1316
- }
1317
- )
1357
+ # Handle tool returns - similar pattern to Anthropic
1358
+ if self.tool_returns:
1359
+ for tool_return in self.tool_returns:
1360
+ if not tool_return.tool_call_id:
1361
+ raise TypeError("OpenAI Responses API requires tool_call_id to be set.")
1362
+ func_response = truncate_tool_return(tool_return.func_response, tool_return_truncation_chars)
1363
+ message_dicts.append(
1364
+ {
1365
+ "type": "function_call_output",
1366
+ "call_id": tool_return.tool_call_id[:max_tool_id_length] if max_tool_id_length else tool_return.tool_call_id,
1367
+ "output": func_response,
1368
+ }
1369
+ )
1370
+ else:
1371
+ # Legacy fallback for old message format
1372
+ assert self.tool_call_id is not None, vars(self)
1373
+ assert len(self.content) == 1 and isinstance(self.content[0], TextContent), vars(self)
1374
+ legacy_output = truncate_tool_return(self.content[0].text, tool_return_truncation_chars)
1375
+ message_dicts.append(
1376
+ {
1377
+ "type": "function_call_output",
1378
+ "call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1379
+ "output": legacy_output,
1380
+ }
1381
+ )
1318
1382
 
1319
1383
  else:
1320
1384
  raise ValueError(self.role)
@@ -1325,11 +1389,16 @@ class Message(BaseMessage):
1325
1389
  def to_openai_responses_dicts_from_list(
1326
1390
  messages: List[Message],
1327
1391
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1392
+ tool_return_truncation_chars: Optional[int] = None,
1328
1393
  ) -> List[dict]:
1329
1394
  messages = Message.filter_messages_for_llm_api(messages)
1330
1395
  result = []
1331
1396
  for message in messages:
1332
- result.extend(message.to_openai_responses_dicts(max_tool_id_length=max_tool_id_length))
1397
+ result.extend(
1398
+ message.to_openai_responses_dicts(
1399
+ max_tool_id_length=max_tool_id_length, tool_return_truncation_chars=tool_return_truncation_chars
1400
+ )
1401
+ )
1333
1402
  return result
1334
1403
 
1335
1404
  def to_anthropic_dict(
@@ -1340,6 +1409,7 @@ class Message(BaseMessage):
1340
1409
  # if true, then treat the content field as AssistantMessage
1341
1410
  native_content: bool = False,
1342
1411
  strip_request_heartbeat: bool = False,
1412
+ tool_return_truncation_chars: Optional[int] = None,
1343
1413
  ) -> dict | None:
1344
1414
  """
1345
1415
  Convert to an Anthropic message dictionary
@@ -1515,11 +1585,12 @@ class Message(BaseMessage):
1515
1585
  for tool_return in self.tool_returns:
1516
1586
  if not tool_return.tool_call_id:
1517
1587
  raise TypeError("Anthropic API requires tool_use_id to be set.")
1588
+ func_response = truncate_tool_return(tool_return.func_response, tool_return_truncation_chars)
1518
1589
  content.append(
1519
1590
  {
1520
1591
  "type": "tool_result",
1521
1592
  "tool_use_id": tool_return.tool_call_id,
1522
- "content": tool_return.func_response,
1593
+ "content": func_response,
1523
1594
  }
1524
1595
  )
1525
1596
  if content:
@@ -1532,6 +1603,7 @@ class Message(BaseMessage):
1532
1603
  raise TypeError("Anthropic API requires tool_use_id to be set.")
1533
1604
 
1534
1605
  # This is for legacy reasons
1606
+ legacy_content = truncate_tool_return(text_content, tool_return_truncation_chars)
1535
1607
  anthropic_message = {
1536
1608
  "role": "user", # NOTE: diff
1537
1609
  "content": [
@@ -1539,7 +1611,7 @@ class Message(BaseMessage):
1539
1611
  {
1540
1612
  "type": "tool_result",
1541
1613
  "tool_use_id": self.tool_call_id,
1542
- "content": text_content,
1614
+ "content": legacy_content,
1543
1615
  }
1544
1616
  ],
1545
1617
  }
@@ -1558,6 +1630,7 @@ class Message(BaseMessage):
1558
1630
  # if true, then treat the content field as AssistantMessage
1559
1631
  native_content: bool = False,
1560
1632
  strip_request_heartbeat: bool = False,
1633
+ tool_return_truncation_chars: Optional[int] = None,
1561
1634
  ) -> List[dict]:
1562
1635
  messages = Message.filter_messages_for_llm_api(messages)
1563
1636
  result = [
@@ -1567,6 +1640,7 @@ class Message(BaseMessage):
1567
1640
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1568
1641
  native_content=native_content,
1569
1642
  strip_request_heartbeat=strip_request_heartbeat,
1643
+ tool_return_truncation_chars=tool_return_truncation_chars,
1570
1644
  )
1571
1645
  for m in messages
1572
1646
  ]
@@ -1580,6 +1654,7 @@ class Message(BaseMessage):
1580
1654
  # if true, then treat the content field as AssistantMessage
1581
1655
  native_content: bool = False,
1582
1656
  strip_request_heartbeat: bool = False,
1657
+ tool_return_truncation_chars: Optional[int] = None,
1583
1658
  ) -> dict | None:
1584
1659
  """
1585
1660
  Go from Message class to Google AI REST message object
@@ -1717,34 +1792,75 @@ class Message(BaseMessage):
1717
1792
 
1718
1793
  elif self.role == "tool":
1719
1794
  # NOTE: Significantly different tool calling format, more similar to function calling format
1720
- assert self.tool_call_id is not None, vars(self)
1721
1795
 
1722
- if self.name is None:
1723
- logger.warning("Couldn't find function name on tool call, defaulting to tool ID instead.")
1724
- function_name = self.tool_call_id
1796
+ # Handle tool returns - similar pattern to Anthropic
1797
+ if self.tool_returns:
1798
+ parts = []
1799
+ for tool_return in self.tool_returns:
1800
+ if not tool_return.tool_call_id:
1801
+ raise TypeError("Google AI API requires tool_call_id to be set.")
1802
+
1803
+ # Use the function name if available, otherwise use tool_call_id
1804
+ function_name = self.name if self.name else tool_return.tool_call_id
1805
+
1806
+ # Truncate the tool return if needed
1807
+ func_response = truncate_tool_return(tool_return.func_response, tool_return_truncation_chars)
1808
+
1809
+ # NOTE: Google AI API wants the function response as JSON only, no string
1810
+ try:
1811
+ function_response = parse_json(func_response)
1812
+ except:
1813
+ function_response = {"function_response": func_response}
1814
+
1815
+ parts.append(
1816
+ {
1817
+ "functionResponse": {
1818
+ "name": function_name,
1819
+ "response": {
1820
+ "name": function_name, # NOTE: name twice... why?
1821
+ "content": function_response,
1822
+ },
1823
+ }
1824
+ }
1825
+ )
1826
+
1827
+ google_ai_message = {
1828
+ "role": "function",
1829
+ "parts": parts,
1830
+ }
1725
1831
  else:
1726
- function_name = self.name
1832
+ # Legacy fallback for old message format
1833
+ assert self.tool_call_id is not None, vars(self)
1727
1834
 
1728
- # NOTE: Google AI API wants the function response as JSON only, no string
1729
- try:
1730
- function_response = parse_json(text_content)
1731
- except:
1732
- function_response = {"function_response": text_content}
1835
+ if self.name is None:
1836
+ logger.warning("Couldn't find function name on tool call, defaulting to tool ID instead.")
1837
+ function_name = self.tool_call_id
1838
+ else:
1839
+ function_name = self.name
1733
1840
 
1734
- google_ai_message = {
1735
- "role": "function",
1736
- "parts": [
1737
- {
1738
- "functionResponse": {
1739
- "name": function_name,
1740
- "response": {
1741
- "name": function_name, # NOTE: name twice... why?
1742
- "content": function_response,
1743
- },
1841
+ # Truncate the legacy content if needed
1842
+ legacy_content = truncate_tool_return(text_content, tool_return_truncation_chars)
1843
+
1844
+ # NOTE: Google AI API wants the function response as JSON only, no string
1845
+ try:
1846
+ function_response = parse_json(legacy_content)
1847
+ except:
1848
+ function_response = {"function_response": legacy_content}
1849
+
1850
+ google_ai_message = {
1851
+ "role": "function",
1852
+ "parts": [
1853
+ {
1854
+ "functionResponse": {
1855
+ "name": function_name,
1856
+ "response": {
1857
+ "name": function_name, # NOTE: name twice... why?
1858
+ "content": function_response,
1859
+ },
1860
+ }
1744
1861
  }
1745
- }
1746
- ],
1747
- }
1862
+ ],
1863
+ }
1748
1864
 
1749
1865
  else:
1750
1866
  raise ValueError(self.role)
@@ -1765,6 +1881,7 @@ class Message(BaseMessage):
1765
1881
  current_model: str,
1766
1882
  put_inner_thoughts_in_kwargs: bool = True,
1767
1883
  native_content: bool = False,
1884
+ tool_return_truncation_chars: Optional[int] = None,
1768
1885
  ):
1769
1886
  messages = Message.filter_messages_for_llm_api(messages)
1770
1887
  result = [
@@ -1772,6 +1889,7 @@ class Message(BaseMessage):
1772
1889
  current_model=current_model,
1773
1890
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1774
1891
  native_content=native_content,
1892
+ tool_return_truncation_chars=tool_return_truncation_chars,
1775
1893
  )
1776
1894
  for m in messages
1777
1895
  ]