letta-nightly 0.11.7.dev20251007104119__py3-none-any.whl → 0.12.0.dev20251009104148__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 (151) hide show
  1. letta/__init__.py +1 -1
  2. letta/adapters/letta_llm_adapter.py +1 -0
  3. letta/adapters/letta_llm_request_adapter.py +0 -1
  4. letta/adapters/letta_llm_stream_adapter.py +7 -2
  5. letta/adapters/simple_llm_request_adapter.py +88 -0
  6. letta/adapters/simple_llm_stream_adapter.py +192 -0
  7. letta/agents/agent_loop.py +6 -0
  8. letta/agents/ephemeral_summary_agent.py +2 -1
  9. letta/agents/helpers.py +142 -6
  10. letta/agents/letta_agent.py +13 -33
  11. letta/agents/letta_agent_batch.py +2 -4
  12. letta/agents/letta_agent_v2.py +87 -77
  13. letta/agents/letta_agent_v3.py +927 -0
  14. letta/agents/voice_agent.py +2 -6
  15. letta/constants.py +8 -4
  16. letta/database_utils.py +161 -0
  17. letta/errors.py +40 -0
  18. letta/functions/function_sets/base.py +84 -4
  19. letta/functions/function_sets/multi_agent.py +0 -3
  20. letta/functions/schema_generator.py +113 -71
  21. letta/groups/dynamic_multi_agent.py +3 -2
  22. letta/groups/helpers.py +1 -2
  23. letta/groups/round_robin_multi_agent.py +3 -2
  24. letta/groups/sleeptime_multi_agent.py +3 -2
  25. letta/groups/sleeptime_multi_agent_v2.py +1 -1
  26. letta/groups/sleeptime_multi_agent_v3.py +17 -17
  27. letta/groups/supervisor_multi_agent.py +84 -80
  28. letta/helpers/converters.py +3 -0
  29. letta/helpers/message_helper.py +4 -0
  30. letta/helpers/tool_rule_solver.py +92 -5
  31. letta/interfaces/anthropic_streaming_interface.py +409 -0
  32. letta/interfaces/gemini_streaming_interface.py +296 -0
  33. letta/interfaces/openai_streaming_interface.py +752 -1
  34. letta/llm_api/anthropic_client.py +127 -16
  35. letta/llm_api/bedrock_client.py +4 -2
  36. letta/llm_api/deepseek_client.py +4 -1
  37. letta/llm_api/google_vertex_client.py +124 -42
  38. letta/llm_api/groq_client.py +4 -1
  39. letta/llm_api/llm_api_tools.py +11 -4
  40. letta/llm_api/llm_client_base.py +6 -2
  41. letta/llm_api/openai.py +32 -2
  42. letta/llm_api/openai_client.py +423 -18
  43. letta/llm_api/xai_client.py +4 -1
  44. letta/main.py +9 -5
  45. letta/memory.py +1 -0
  46. letta/orm/__init__.py +2 -1
  47. letta/orm/agent.py +10 -0
  48. letta/orm/block.py +7 -16
  49. letta/orm/blocks_agents.py +8 -2
  50. letta/orm/files_agents.py +2 -0
  51. letta/orm/job.py +7 -5
  52. letta/orm/mcp_oauth.py +1 -0
  53. letta/orm/message.py +21 -6
  54. letta/orm/organization.py +2 -0
  55. letta/orm/provider.py +6 -2
  56. letta/orm/run.py +71 -0
  57. letta/orm/run_metrics.py +82 -0
  58. letta/orm/sandbox_config.py +7 -1
  59. letta/orm/sqlalchemy_base.py +0 -306
  60. letta/orm/step.py +6 -5
  61. letta/orm/step_metrics.py +5 -5
  62. letta/otel/tracing.py +28 -3
  63. letta/plugins/defaults.py +4 -4
  64. letta/prompts/system_prompts/__init__.py +2 -0
  65. letta/prompts/system_prompts/letta_v1.py +25 -0
  66. letta/schemas/agent.py +3 -2
  67. letta/schemas/agent_file.py +9 -3
  68. letta/schemas/block.py +23 -10
  69. letta/schemas/enums.py +21 -2
  70. letta/schemas/job.py +17 -4
  71. letta/schemas/letta_message_content.py +71 -2
  72. letta/schemas/letta_stop_reason.py +5 -5
  73. letta/schemas/llm_config.py +53 -3
  74. letta/schemas/memory.py +1 -1
  75. letta/schemas/message.py +564 -117
  76. letta/schemas/openai/responses_request.py +64 -0
  77. letta/schemas/providers/__init__.py +2 -0
  78. letta/schemas/providers/anthropic.py +16 -0
  79. letta/schemas/providers/ollama.py +115 -33
  80. letta/schemas/providers/openrouter.py +52 -0
  81. letta/schemas/providers/vllm.py +2 -1
  82. letta/schemas/run.py +48 -42
  83. letta/schemas/run_metrics.py +21 -0
  84. letta/schemas/step.py +2 -2
  85. letta/schemas/step_metrics.py +1 -1
  86. letta/schemas/tool.py +15 -107
  87. letta/schemas/tool_rule.py +88 -5
  88. letta/serialize_schemas/marshmallow_agent.py +1 -0
  89. letta/server/db.py +79 -408
  90. letta/server/rest_api/app.py +61 -10
  91. letta/server/rest_api/dependencies.py +14 -0
  92. letta/server/rest_api/redis_stream_manager.py +19 -8
  93. letta/server/rest_api/routers/v1/agents.py +364 -292
  94. letta/server/rest_api/routers/v1/blocks.py +14 -20
  95. letta/server/rest_api/routers/v1/identities.py +45 -110
  96. letta/server/rest_api/routers/v1/internal_templates.py +21 -0
  97. letta/server/rest_api/routers/v1/jobs.py +23 -6
  98. letta/server/rest_api/routers/v1/messages.py +1 -1
  99. letta/server/rest_api/routers/v1/runs.py +149 -99
  100. letta/server/rest_api/routers/v1/sandbox_configs.py +10 -19
  101. letta/server/rest_api/routers/v1/tools.py +281 -594
  102. letta/server/rest_api/routers/v1/voice.py +1 -1
  103. letta/server/rest_api/streaming_response.py +29 -29
  104. letta/server/rest_api/utils.py +122 -64
  105. letta/server/server.py +160 -887
  106. letta/services/agent_manager.py +236 -919
  107. letta/services/agent_serialization_manager.py +16 -0
  108. letta/services/archive_manager.py +0 -100
  109. letta/services/block_manager.py +211 -168
  110. letta/services/context_window_calculator/token_counter.py +1 -1
  111. letta/services/file_manager.py +1 -1
  112. letta/services/files_agents_manager.py +24 -33
  113. letta/services/group_manager.py +0 -142
  114. letta/services/helpers/agent_manager_helper.py +7 -2
  115. letta/services/helpers/run_manager_helper.py +69 -0
  116. letta/services/job_manager.py +96 -411
  117. letta/services/lettuce/__init__.py +6 -0
  118. letta/services/lettuce/lettuce_client_base.py +86 -0
  119. letta/services/mcp_manager.py +38 -6
  120. letta/services/message_manager.py +165 -362
  121. letta/services/organization_manager.py +0 -36
  122. letta/services/passage_manager.py +0 -345
  123. letta/services/provider_manager.py +0 -80
  124. letta/services/run_manager.py +364 -0
  125. letta/services/sandbox_config_manager.py +0 -234
  126. letta/services/step_manager.py +62 -39
  127. letta/services/summarizer/summarizer.py +9 -7
  128. letta/services/telemetry_manager.py +0 -16
  129. letta/services/tool_executor/builtin_tool_executor.py +35 -0
  130. letta/services/tool_executor/core_tool_executor.py +397 -2
  131. letta/services/tool_executor/files_tool_executor.py +3 -3
  132. letta/services/tool_executor/multi_agent_tool_executor.py +30 -15
  133. letta/services/tool_executor/tool_execution_manager.py +6 -8
  134. letta/services/tool_executor/tool_executor_base.py +3 -3
  135. letta/services/tool_manager.py +85 -339
  136. letta/services/tool_sandbox/base.py +24 -13
  137. letta/services/tool_sandbox/e2b_sandbox.py +16 -1
  138. letta/services/tool_schema_generator.py +123 -0
  139. letta/services/user_manager.py +0 -99
  140. letta/settings.py +20 -4
  141. letta/system.py +5 -1
  142. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/METADATA +3 -5
  143. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/RECORD +146 -135
  144. letta/agents/temporal/activities/__init__.py +0 -4
  145. letta/agents/temporal/activities/example_activity.py +0 -7
  146. letta/agents/temporal/activities/prepare_messages.py +0 -10
  147. letta/agents/temporal/temporal_agent_workflow.py +0 -56
  148. letta/agents/temporal/types.py +0 -25
  149. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/WHEEL +0 -0
  150. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/entry_points.txt +0 -0
  151. {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.12.0.dev20251009104148.dist-info}/licenses/LICENSE +0 -0
letta/schemas/message.py CHANGED
@@ -11,9 +11,10 @@ from enum import Enum
11
11
  from typing import Annotated, Any, Dict, List, Literal, Optional, Union
12
12
 
13
13
  from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall, Function as OpenAIFunction
14
+ from openai.types.responses import ResponseReasoningItem
14
15
  from pydantic import BaseModel, Field, field_validator, model_validator
15
16
 
16
- from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG, TOOL_CALL_ID_MAX_LEN
17
+ from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG, REQUEST_HEARTBEAT_PARAM, TOOL_CALL_ID_MAX_LEN
17
18
  from letta.helpers.datetime_helpers import get_utc_time, is_utc_datetime
18
19
  from letta.helpers.json_helpers import json_dumps
19
20
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_VERTEX
@@ -25,6 +26,7 @@ from letta.schemas.letta_message import (
25
26
  AssistantMessage,
26
27
  HiddenReasoningMessage,
27
28
  LettaMessage,
29
+ MessageType,
28
30
  ReasoningMessage,
29
31
  SystemMessage,
30
32
  ToolCall,
@@ -38,7 +40,9 @@ from letta.schemas.letta_message_content import (
38
40
  OmittedReasoningContent,
39
41
  ReasoningContent,
40
42
  RedactedReasoningContent,
43
+ SummarizedReasoningContent,
41
44
  TextContent,
45
+ ToolCallContent,
42
46
  ToolReturnContent,
43
47
  get_letta_message_content_union_str_json_schema,
44
48
  )
@@ -192,6 +196,7 @@ class Message(BaseMessage):
192
196
  tool_call_id: Optional[str] = Field(default=None, description="The ID of the tool call. Only applicable for role tool.")
193
197
  # Extras
194
198
  step_id: Optional[str] = Field(default=None, description="The id of the step that this message was created in.")
199
+ run_id: Optional[str] = Field(default=None, description="The id of the run that this message was created in.")
195
200
  otid: Optional[str] = Field(default=None, description="The offline threading id associated with this message")
196
201
  tool_returns: Optional[List[ToolReturn]] = Field(default=None, description="Tool execution return information for prior tool calls")
197
202
  group_id: Optional[str] = Field(default=None, description="The multi-agent group that the message was sent in")
@@ -208,6 +213,13 @@ class Message(BaseMessage):
208
213
  # This overrides the optional base orm schema, created_at MUST exist on all messages objects
209
214
  created_at: datetime = Field(default_factory=get_utc_time, description="The timestamp when the object was created.")
210
215
 
216
+ # validate that run_id is set
217
+ # @model_validator(mode="after")
218
+ # def validate_run_id(self):
219
+ # if self.run_id is None:
220
+ # raise ValueError("Run ID is required")
221
+ # return self
222
+
211
223
  @field_validator("role")
212
224
  @classmethod
213
225
  def validate_role(cls, v: str) -> str:
@@ -239,6 +251,7 @@ class Message(BaseMessage):
239
251
  assistant_message_tool_kwarg: str = DEFAULT_MESSAGE_TOOL_KWARG,
240
252
  reverse: bool = True,
241
253
  include_err: Optional[bool] = None,
254
+ text_is_assistant_message: bool = False,
242
255
  ) -> List[LettaMessage]:
243
256
  if use_assistant_message:
244
257
  message_ids_to_remove = []
@@ -270,6 +283,7 @@ class Message(BaseMessage):
270
283
  assistant_message_tool_kwarg=assistant_message_tool_kwarg,
271
284
  reverse=reverse,
272
285
  include_err=include_err,
286
+ text_is_assistant_message=text_is_assistant_message,
273
287
  )
274
288
  ]
275
289
 
@@ -280,12 +294,15 @@ class Message(BaseMessage):
280
294
  assistant_message_tool_kwarg: str = DEFAULT_MESSAGE_TOOL_KWARG,
281
295
  reverse: bool = True,
282
296
  include_err: Optional[bool] = None,
297
+ text_is_assistant_message: bool = False,
283
298
  ) -> List[LettaMessage]:
284
299
  """Convert message object (in DB format) to the style used by the original Letta API"""
300
+
285
301
  messages = []
286
302
  if self.role == MessageRole.assistant:
287
303
  if self.content:
288
- messages.extend(self._convert_reasoning_messages())
304
+ messages.extend(self._convert_reasoning_messages(text_is_assistant_message=text_is_assistant_message))
305
+
289
306
  if self.tool_calls is not None:
290
307
  messages.extend(
291
308
  self._convert_tool_call_messages(
@@ -296,14 +313,14 @@ class Message(BaseMessage):
296
313
  ),
297
314
  )
298
315
  elif self.role == MessageRole.tool:
299
- messages.append(self._convert_tool_return_message())
316
+ messages.extend(self._convert_tool_return_message())
300
317
  elif self.role == MessageRole.user:
301
318
  messages.append(self._convert_user_message())
302
319
  elif self.role == MessageRole.system:
303
320
  messages.append(self._convert_system_message())
304
321
  elif self.role == MessageRole.approval:
305
322
  if self.content:
306
- messages.extend(self._convert_reasoning_messages())
323
+ messages.extend(self._convert_reasoning_messages(text_is_assistant_message=text_is_assistant_message))
307
324
  if self.tool_calls is not None:
308
325
  tool_calls = self._convert_tool_call_messages()
309
326
  assert len(tool_calls) == 1
@@ -317,6 +334,7 @@ class Message(BaseMessage):
317
334
  approve=self.approve,
318
335
  approval_request_id=self.approval_request_id,
319
336
  reason=self.denial_reason,
337
+ run_id=self.run_id,
320
338
  )
321
339
  messages.append(approval_response_message)
322
340
  else:
@@ -324,30 +342,37 @@ class Message(BaseMessage):
324
342
 
325
343
  return messages[::-1] if reverse else messages
326
344
 
327
- def _convert_reasoning_messages(self, current_message_count: int = 0) -> List[LettaMessage]:
345
+ def _convert_reasoning_messages(
346
+ self,
347
+ current_message_count: int = 0,
348
+ text_is_assistant_message: bool = False, # For v3 loop, set to True
349
+ ) -> List[LettaMessage]:
328
350
  messages = []
329
- # Check for ReACT-style COT inside of TextContent
330
- if len(self.content) == 1 and isinstance(self.content[0], TextContent):
351
+
352
+ for content_part in self.content:
331
353
  otid = Message.generate_otid_from_id(self.id, current_message_count + len(messages))
332
- messages.append(
333
- ReasoningMessage(
334
- id=self.id,
335
- date=self.created_at,
336
- reasoning=self.content[0].text,
337
- name=self.name,
338
- otid=otid,
339
- sender_id=self.sender_id,
340
- step_id=self.step_id,
341
- is_err=self.is_err,
342
- )
343
- )
344
- # Otherwise, we may have a list of multiple types
345
- else:
346
- # TODO we can probably collapse these two cases into a single loop
347
- for content_part in self.content:
348
- otid = Message.generate_otid_from_id(self.id, current_message_count + len(messages))
349
- if isinstance(content_part, TextContent):
350
- # COT
354
+
355
+ if isinstance(content_part, TextContent):
356
+ if text_is_assistant_message:
357
+ # .content is assistant message
358
+ if messages and messages[-1].message_type == MessageType.assistant_message:
359
+ messages[-1].content += content_part.text
360
+ else:
361
+ messages.append(
362
+ AssistantMessage(
363
+ id=self.id,
364
+ date=self.created_at,
365
+ content=content_part.text,
366
+ name=self.name,
367
+ otid=otid,
368
+ sender_id=self.sender_id,
369
+ step_id=self.step_id,
370
+ is_err=self.is_err,
371
+ run_id=self.run_id,
372
+ )
373
+ )
374
+ else:
375
+ # .content is COT
351
376
  messages.append(
352
377
  ReasoningMessage(
353
378
  id=self.id,
@@ -358,10 +383,15 @@ class Message(BaseMessage):
358
383
  sender_id=self.sender_id,
359
384
  step_id=self.step_id,
360
385
  is_err=self.is_err,
386
+ run_id=self.run_id,
361
387
  )
362
388
  )
363
- elif isinstance(content_part, ReasoningContent):
364
- # "native" COT
389
+
390
+ elif isinstance(content_part, ReasoningContent):
391
+ # "native" COT
392
+ if messages and messages[-1].message_type == MessageType.reasoning_message:
393
+ messages[-1].reasoning += content_part.reasoning
394
+ else:
365
395
  messages.append(
366
396
  ReasoningMessage(
367
397
  id=self.id,
@@ -373,41 +403,87 @@ class Message(BaseMessage):
373
403
  otid=otid,
374
404
  step_id=self.step_id,
375
405
  is_err=self.is_err,
406
+ run_id=self.run_id,
376
407
  )
377
408
  )
378
- elif isinstance(content_part, RedactedReasoningContent):
379
- # "native" redacted/hidden COT
409
+
410
+ elif isinstance(content_part, SummarizedReasoningContent):
411
+ # TODO remove the cast and just return the native type
412
+ casted_content_part = content_part.to_reasoning_content()
413
+ if casted_content_part is not None:
380
414
  messages.append(
381
- HiddenReasoningMessage(
415
+ ReasoningMessage(
382
416
  id=self.id,
383
417
  date=self.created_at,
384
- state="redacted",
385
- hidden_reasoning=content_part.data,
418
+ reasoning=casted_content_part.reasoning,
419
+ source="reasoner_model", # TODO do we want to tag like this?
420
+ signature=casted_content_part.signature,
386
421
  name=self.name,
387
422
  otid=otid,
388
- sender_id=self.sender_id,
389
423
  step_id=self.step_id,
390
424
  is_err=self.is_err,
425
+ run_id=self.run_id,
391
426
  )
392
427
  )
393
- elif isinstance(content_part, OmittedReasoningContent):
394
- # Special case for "hidden reasoning" models like o1/o3
395
- # NOTE: we also have to think about how to return this during streaming
396
- messages.append(
397
- HiddenReasoningMessage(
398
- id=self.id,
399
- date=self.created_at,
400
- state="omitted",
401
- name=self.name,
402
- otid=otid,
403
- step_id=self.step_id,
404
- is_err=self.is_err,
405
- )
428
+
429
+ elif isinstance(content_part, RedactedReasoningContent):
430
+ # "native" redacted/hidden COT
431
+ messages.append(
432
+ HiddenReasoningMessage(
433
+ id=self.id,
434
+ date=self.created_at,
435
+ state="redacted",
436
+ hidden_reasoning=content_part.data,
437
+ name=self.name,
438
+ otid=otid,
439
+ sender_id=self.sender_id,
440
+ step_id=self.step_id,
441
+ is_err=self.is_err,
442
+ run_id=self.run_id,
406
443
  )
407
- else:
408
- warnings.warn(f"Unrecognized content part in assistant message: {content_part}")
444
+ )
445
+
446
+ elif isinstance(content_part, OmittedReasoningContent):
447
+ # Special case for "hidden reasoning" models like o1/o3
448
+ # NOTE: we also have to think about how to return this during streaming
449
+ messages.append(
450
+ HiddenReasoningMessage(
451
+ id=self.id,
452
+ date=self.created_at,
453
+ state="omitted",
454
+ name=self.name,
455
+ otid=otid,
456
+ step_id=self.step_id,
457
+ is_err=self.is_err,
458
+ run_id=self.run_id,
459
+ )
460
+ )
461
+
462
+ else:
463
+ warnings.warn(f"Unrecognized content part in assistant message: {content_part}")
464
+
409
465
  return messages
410
466
 
467
+ def _convert_assistant_message(
468
+ self,
469
+ ) -> AssistantMessage:
470
+ if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
471
+ text_content = self.content[0].text
472
+ else:
473
+ raise ValueError(f"Invalid assistant message (no text object on message): {self.content}")
474
+
475
+ return AssistantMessage(
476
+ id=self.id,
477
+ date=self.created_at,
478
+ content=text_content,
479
+ name=self.name,
480
+ otid=self.otid,
481
+ sender_id=self.sender_id,
482
+ step_id=self.step_id,
483
+ # is_err=self.is_err,
484
+ run_id=self.run_id,
485
+ )
486
+
411
487
  def _convert_tool_call_messages(
412
488
  self,
413
489
  current_message_count: int = 0,
@@ -438,6 +514,7 @@ class Message(BaseMessage):
438
514
  sender_id=self.sender_id,
439
515
  step_id=self.step_id,
440
516
  is_err=self.is_err,
517
+ run_id=self.run_id,
441
518
  )
442
519
  )
443
520
  else:
@@ -455,49 +532,135 @@ class Message(BaseMessage):
455
532
  sender_id=self.sender_id,
456
533
  step_id=self.step_id,
457
534
  is_err=self.is_err,
535
+ run_id=self.run_id,
458
536
  )
459
537
  )
460
538
  return messages
461
539
 
462
- def _convert_tool_return_message(self) -> ToolReturnMessage:
463
- """Convert tool role message to ToolReturnMessage
540
+ def _convert_tool_return_message(self) -> List[ToolReturnMessage]:
541
+ """Convert tool role message to ToolReturnMessage.
464
542
 
465
- the tool return is packaged as follows:
543
+ The tool return is packaged as follows:
466
544
  packaged_message = {
467
545
  "status": "OK" if was_success else "Failed",
468
546
  "message": response_string,
469
547
  "time": formatted_time,
470
548
  }
549
+
550
+ Returns:
551
+ List[ToolReturnMessage]: Converted tool return messages
552
+
553
+ Raises:
554
+ ValueError: If message role is not 'tool', parsing fails, or no valid content exists
471
555
  """
472
- if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
473
- text_content = self.content[0].text
474
- else:
475
- raise ValueError(f"Invalid tool return (no text object on message): {self.content}")
556
+ if self.role != MessageRole.tool:
557
+ raise ValueError(f"Cannot convert message of type {self.role} to ToolReturnMessage")
558
+
559
+ if self.tool_returns:
560
+ return self._convert_explicit_tool_returns()
561
+
562
+ return self._convert_legacy_tool_return()
476
563
 
564
+ def _convert_explicit_tool_returns(self) -> List[ToolReturnMessage]:
565
+ """Convert explicit tool returns to ToolReturnMessage list."""
566
+ tool_returns = []
567
+
568
+ for index, tool_return in enumerate(self.tool_returns):
569
+ parsed_data = self._parse_tool_response(tool_return.func_response)
570
+
571
+ tool_returns.append(
572
+ self._create_tool_return_message(
573
+ message_text=parsed_data["message"],
574
+ status=parsed_data["status"],
575
+ tool_call_id=tool_return.tool_call_id,
576
+ stdout=tool_return.stdout,
577
+ stderr=tool_return.stderr,
578
+ otid_index=index,
579
+ )
580
+ )
581
+
582
+ return tool_returns
583
+
584
+ def _convert_legacy_tool_return(self) -> List[ToolReturnMessage]:
585
+ """Convert legacy single text content to ToolReturnMessage."""
586
+ if not self._has_single_text_content():
587
+ raise ValueError(f"No valid tool returns to convert: {self}")
588
+
589
+ text_content = self.content[0].text
590
+ parsed_data = self._parse_tool_response(text_content)
591
+
592
+ return [
593
+ self._create_tool_return_message(
594
+ message_text=parsed_data["message"],
595
+ status=parsed_data["status"],
596
+ tool_call_id=self.tool_call_id,
597
+ stdout=None,
598
+ stderr=None,
599
+ otid_index=0,
600
+ )
601
+ ]
602
+
603
+ def _has_single_text_content(self) -> bool:
604
+ """Check if message has exactly one text content item."""
605
+ return self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent)
606
+
607
+ def _parse_tool_response(self, response_text: str) -> dict:
608
+ """Parse tool response JSON and extract message and status.
609
+
610
+ Args:
611
+ response_text: Raw JSON response text
612
+
613
+ Returns:
614
+ Dictionary with 'message' and 'status' keys
615
+
616
+ Raises:
617
+ ValueError: If JSON parsing fails
618
+ """
477
619
  try:
478
- function_return = parse_json(text_content)
479
- message_text = str(function_return.get("message", text_content))
480
- status = self._parse_tool_status(function_return["status"])
481
- except json.JSONDecodeError:
482
- raise ValueError(f"Failed to decode function return: {text_content}")
620
+ function_return = parse_json(response_text)
621
+ return {
622
+ "message": str(function_return.get("message", response_text)),
623
+ "status": self._parse_tool_status(function_return.get("status", "OK")),
624
+ }
625
+ except json.JSONDecodeError as e:
626
+ raise ValueError(f"Failed to decode function return: {response_text}") from e
483
627
 
484
- # if self.tool_call_id is None:
485
- # import pdb;pdb.set_trace()
486
- assert self.tool_call_id is not None
628
+ def _create_tool_return_message(
629
+ self,
630
+ message_text: str,
631
+ status: str,
632
+ tool_call_id: Optional[str],
633
+ stdout: Optional[str],
634
+ stderr: Optional[str],
635
+ otid_index: int,
636
+ ) -> ToolReturnMessage:
637
+ """Create a ToolReturnMessage with common attributes.
487
638
 
639
+ Args:
640
+ message_text: The tool return message text
641
+ status: Tool execution status
642
+ tool_call_id: Optional tool call identifier
643
+ stdout: Optional standard output
644
+ stderr: Optional standard error
645
+ otid_index: Index for OTID generation
646
+
647
+ Returns:
648
+ Configured ToolReturnMessage instance
649
+ """
488
650
  return ToolReturnMessage(
489
651
  id=self.id,
490
652
  date=self.created_at,
491
653
  tool_return=message_text,
492
- status=self.tool_returns[0].status if self.tool_returns else status,
493
- tool_call_id=self.tool_call_id,
494
- stdout=self.tool_returns[0].stdout if self.tool_returns else None,
495
- stderr=self.tool_returns[0].stderr if self.tool_returns else None,
654
+ status=status,
655
+ tool_call_id=tool_call_id,
656
+ stdout=stdout,
657
+ stderr=stderr,
496
658
  name=self.name,
497
- otid=Message.generate_otid_from_id(self.id, 0),
659
+ otid=Message.generate_otid_from_id(self.id, otid_index),
498
660
  sender_id=self.sender_id,
499
661
  step_id=self.step_id,
500
662
  is_err=self.is_err,
663
+ run_id=self.run_id,
501
664
  )
502
665
 
503
666
  @staticmethod
@@ -531,6 +694,7 @@ class Message(BaseMessage):
531
694
  sender_id=self.sender_id,
532
695
  step_id=self.step_id,
533
696
  is_err=self.is_err,
697
+ run_id=self.run_id,
534
698
  )
535
699
 
536
700
  def _convert_system_message(self) -> SystemMessage:
@@ -548,6 +712,7 @@ class Message(BaseMessage):
548
712
  otid=self.otid,
549
713
  sender_id=self.sender_id,
550
714
  step_id=self.step_id,
715
+ run_id=self.run_id,
551
716
  )
552
717
 
553
718
  @staticmethod
@@ -561,6 +726,7 @@ class Message(BaseMessage):
561
726
  name: Optional[str] = None,
562
727
  group_id: Optional[str] = None,
563
728
  tool_returns: Optional[List[ToolReturn]] = None,
729
+ run_id: Optional[str] = None,
564
730
  ) -> Message:
565
731
  """Convert a ChatCompletion message object into a Message object (synced to DB)"""
566
732
  if not created_at:
@@ -622,6 +788,7 @@ class Message(BaseMessage):
622
788
  id=str(id),
623
789
  tool_returns=tool_returns,
624
790
  group_id=group_id,
791
+ run_id=run_id,
625
792
  )
626
793
  else:
627
794
  return Message(
@@ -636,6 +803,7 @@ class Message(BaseMessage):
636
803
  created_at=created_at,
637
804
  tool_returns=tool_returns,
638
805
  group_id=group_id,
806
+ run_id=run_id,
639
807
  )
640
808
 
641
809
  elif "function_call" in openai_message_dict and openai_message_dict["function_call"] is not None:
@@ -671,6 +839,7 @@ class Message(BaseMessage):
671
839
  id=str(id),
672
840
  tool_returns=tool_returns,
673
841
  group_id=group_id,
842
+ run_id=run_id,
674
843
  )
675
844
  else:
676
845
  return Message(
@@ -685,6 +854,7 @@ class Message(BaseMessage):
685
854
  created_at=created_at,
686
855
  tool_returns=tool_returns,
687
856
  group_id=group_id,
857
+ run_id=run_id,
688
858
  )
689
859
 
690
860
  else:
@@ -720,6 +890,7 @@ class Message(BaseMessage):
720
890
  id=str(id),
721
891
  tool_returns=tool_returns,
722
892
  group_id=group_id,
893
+ run_id=run_id,
723
894
  )
724
895
  else:
725
896
  return Message(
@@ -734,6 +905,7 @@ class Message(BaseMessage):
734
905
  created_at=created_at,
735
906
  tool_returns=tool_returns,
736
907
  group_id=group_id,
908
+ run_id=run_id,
737
909
  )
738
910
 
739
911
  def to_openai_dict_search_results(self, max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN) -> dict:
@@ -746,8 +918,13 @@ class Message(BaseMessage):
746
918
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
747
919
  put_inner_thoughts_in_kwargs: bool = False,
748
920
  use_developer_message: bool = False,
921
+ # if true, then treat the content field as AssistantMessage
922
+ native_content: bool = False,
923
+ strip_request_heartbeat: bool = False,
749
924
  ) -> dict | None:
750
925
  """Go from Message class to ChatCompletion message object"""
926
+ assert not (native_content and put_inner_thoughts_in_kwargs), "native_content and put_inner_thoughts_in_kwargs cannot both be true"
927
+
751
928
  if self.role == "approval" and self.tool_calls is None:
752
929
  return None
753
930
 
@@ -763,8 +940,8 @@ class Message(BaseMessage):
763
940
  # Otherwise, check if we have TextContent and multiple other parts
764
941
  elif self.content and len(self.content) > 1:
765
942
  text = [content for content in self.content if isinstance(content, TextContent)]
766
- assert len(text) == 1, f"multiple text content parts found in a single message: {self.content}"
767
- text_content = text[0].text
943
+ # assert len(text) == 1, f"multiple text content parts found in a single message: {self.content}"
944
+ text_content = "\n\n".join([t.text for t in text])
768
945
  parse_content_parts = True
769
946
  else:
770
947
  text_content = None
@@ -788,11 +965,28 @@ class Message(BaseMessage):
788
965
  }
789
966
 
790
967
  elif self.role == "assistant" or self.role == "approval":
791
- assert self.tool_calls is not None or text_content is not None
792
- openai_message = {
793
- "content": None if (put_inner_thoughts_in_kwargs and self.tool_calls is not None) else text_content,
794
- "role": "assistant",
795
- }
968
+ try:
969
+ assert self.tool_calls is not None or text_content is not None, vars(self)
970
+ except AssertionError as e:
971
+ # relax check if this message only contains reasoning content
972
+ if self.content is not None and len(self.content) > 0 and isinstance(self.content[0], ReasoningContent):
973
+ return None
974
+ raise e
975
+
976
+ # if native content, then put it directly inside the content
977
+ if native_content:
978
+ openai_message = {
979
+ # TODO support listed content (if it's possible for role assistant?)
980
+ # "content": self.content,
981
+ "content": text_content, # here content is not reasoning, it's assistant message
982
+ "role": "assistant",
983
+ }
984
+ # otherwise, if inner_thoughts_in_kwargs, hold it for the tool calls
985
+ else:
986
+ openai_message = {
987
+ "content": None if (put_inner_thoughts_in_kwargs and self.tool_calls is not None) else text_content,
988
+ "role": "assistant",
989
+ }
796
990
 
797
991
  if self.tool_calls is not None:
798
992
  if put_inner_thoughts_in_kwargs:
@@ -807,6 +1001,11 @@ class Message(BaseMessage):
807
1001
  ]
808
1002
  else:
809
1003
  openai_message["tool_calls"] = [tool_call.model_dump() for tool_call in self.tool_calls]
1004
+
1005
+ if strip_request_heartbeat:
1006
+ for tool_call_dict in openai_message["tool_calls"]:
1007
+ tool_call_dict.pop(REQUEST_HEARTBEAT_PARAM, None)
1008
+
810
1009
  if max_tool_id_length:
811
1010
  for tool_call_dict in openai_message["tool_calls"]:
812
1011
  tool_call_dict["id"] = tool_call_dict["id"][:max_tool_id_length]
@@ -847,6 +1046,7 @@ class Message(BaseMessage):
847
1046
  put_inner_thoughts_in_kwargs: bool = False,
848
1047
  use_developer_message: bool = False,
849
1048
  ) -> List[dict]:
1049
+ messages = Message.filter_messages_for_llm_api(messages)
850
1050
  result = [
851
1051
  m.to_openai_dict(
852
1052
  max_tool_id_length=max_tool_id_length,
@@ -858,10 +1058,118 @@ class Message(BaseMessage):
858
1058
  result = [m for m in result if m is not None]
859
1059
  return result
860
1060
 
1061
+ def to_openai_responses_dicts(
1062
+ self,
1063
+ max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1064
+ ) -> List[dict]:
1065
+ """Go from Message class to ChatCompletion message object"""
1066
+
1067
+ if self.role == "approval" and self.tool_calls is None:
1068
+ return []
1069
+
1070
+ message_dicts = []
1071
+
1072
+ if self.role == "system":
1073
+ assert len(self.content) == 1 and isinstance(self.content[0], TextContent), vars(self)
1074
+ message_dicts.append(
1075
+ {
1076
+ "role": "developer",
1077
+ "content": self.content[0].text,
1078
+ }
1079
+ )
1080
+
1081
+ elif self.role == "user":
1082
+ # TODO do we need to do a swap to placeholder text here for images?
1083
+ assert all([isinstance(c, TextContent) or isinstance(c, ImageContent) for c in self.content]), vars(self)
1084
+
1085
+ user_dict = {
1086
+ "role": self.role.value if hasattr(self.role, "value") else self.role,
1087
+ # TODO support multi-modal
1088
+ "content": self.content[0].text,
1089
+ }
1090
+
1091
+ # Optional field, do not include if null or invalid
1092
+ if self.name is not None:
1093
+ if bool(re.match(r"^[^\s<|\\/>]+$", self.name)):
1094
+ user_dict["name"] = self.name
1095
+ else:
1096
+ warnings.warn(f"Using OpenAI with invalid 'name' field (name={self.name} role={self.role}).")
1097
+
1098
+ message_dicts.append(user_dict)
1099
+
1100
+ elif self.role == "assistant" or self.role == "approval":
1101
+ assert self.tool_calls is not None or (self.content is not None and len(self.content) > 0)
1102
+
1103
+ # A few things may be in here, firstly reasoning content, secondly assistant messages, thirdly tool calls
1104
+ # TODO check if OpenAI Responses is capable of R->A->T like Anthropic?
1105
+
1106
+ if self.content is not None:
1107
+ for content_part in self.content:
1108
+ if isinstance(content_part, SummarizedReasoningContent):
1109
+ message_dicts.append(
1110
+ {
1111
+ "type": "reasoning",
1112
+ "id": content_part.id,
1113
+ "summary": [{"type": "summary_text", "text": s.text} for s in content_part.summary],
1114
+ "encrypted_content": content_part.encrypted_content,
1115
+ }
1116
+ )
1117
+ elif isinstance(content_part, TextContent):
1118
+ message_dicts.append(
1119
+ {
1120
+ "role": "assistant",
1121
+ "content": content_part.text,
1122
+ }
1123
+ )
1124
+ # else skip
1125
+
1126
+ if self.tool_calls is not None:
1127
+ for tool_call in self.tool_calls:
1128
+ message_dicts.append(
1129
+ {
1130
+ "type": "function_call",
1131
+ "call_id": tool_call.id[:max_tool_id_length] if max_tool_id_length else tool_call.id,
1132
+ "name": tool_call.function.name,
1133
+ "arguments": tool_call.function.arguments,
1134
+ "status": "completed", # TODO check if needed?
1135
+ }
1136
+ )
1137
+
1138
+ elif self.role == "tool":
1139
+ assert self.tool_call_id is not None, vars(self)
1140
+ assert len(self.content) == 1 and isinstance(self.content[0], TextContent), vars(self)
1141
+ message_dicts.append(
1142
+ {
1143
+ "type": "function_call_output",
1144
+ "call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
1145
+ "output": self.content[0].text,
1146
+ }
1147
+ )
1148
+
1149
+ else:
1150
+ raise ValueError(self.role)
1151
+
1152
+ return message_dicts
1153
+
1154
+ @staticmethod
1155
+ def to_openai_responses_dicts_from_list(
1156
+ messages: List[Message],
1157
+ max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1158
+ ) -> List[dict]:
1159
+ messages = Message.filter_messages_for_llm_api(messages)
1160
+ result = []
1161
+ for message in messages:
1162
+ result.extend(message.to_openai_responses_dicts(max_tool_id_length=max_tool_id_length))
1163
+ return result
1164
+
861
1165
  def to_anthropic_dict(
862
1166
  self,
1167
+ current_model: str,
863
1168
  inner_thoughts_xml_tag="thinking",
864
1169
  put_inner_thoughts_in_kwargs: bool = False,
1170
+ # if true, then treat the content field as AssistantMessage
1171
+ native_content: bool = False,
1172
+ strip_request_heartbeat: bool = False,
865
1173
  ) -> dict | None:
866
1174
  """
867
1175
  Convert to an Anthropic message dictionary
@@ -869,6 +1177,8 @@ class Message(BaseMessage):
869
1177
  Args:
870
1178
  inner_thoughts_xml_tag (str): The XML tag to wrap around inner thoughts
871
1179
  """
1180
+ assert not (native_content and put_inner_thoughts_in_kwargs), "native_content and put_inner_thoughts_in_kwargs cannot both be true"
1181
+
872
1182
  if self.role == "approval" and self.tool_calls is None:
873
1183
  return None
874
1184
 
@@ -929,43 +1239,80 @@ class Message(BaseMessage):
929
1239
  }
930
1240
 
931
1241
  elif self.role == "assistant" or self.role == "approval":
932
- assert self.tool_calls is not None or text_content is not None
1242
+ # assert self.tool_calls is not None or text_content is not None, vars(self)
1243
+ assert self.tool_calls is not None or len(self.content) > 0
933
1244
  anthropic_message = {
934
1245
  "role": "assistant",
935
1246
  }
936
1247
  content = []
937
- # COT / reasoning / thinking
938
- if self.content is not None and len(self.content) >= 1:
939
- for content_part in self.content:
940
- if isinstance(content_part, ReasoningContent):
941
- content.append(
942
- {
943
- "type": "thinking",
944
- "thinking": content_part.reasoning,
945
- "signature": content_part.signature,
946
- }
947
- )
948
- if isinstance(content_part, RedactedReasoningContent):
949
- content.append(
950
- {
951
- "type": "redacted_thinking",
952
- "data": content_part.data,
953
- }
954
- )
955
- if isinstance(content_part, TextContent):
956
- content.append(
957
- {
958
- "type": "text",
959
- "text": content_part.text,
960
- }
961
- )
962
- elif text_content is not None:
963
- content.append(
964
- {
965
- "type": "text",
966
- "text": add_xml_tag(string=text_content, xml_tag=inner_thoughts_xml_tag),
967
- }
968
- )
1248
+ if native_content:
1249
+ # No special handling for TextContent
1250
+ if self.content is not None:
1251
+ for content_part in self.content:
1252
+ # TextContent, ImageContent, ToolCallContent, ToolReturnContent, ReasoningContent, RedactedReasoningContent, OmittedReasoningContent
1253
+ if isinstance(content_part, ReasoningContent):
1254
+ if current_model == self.model:
1255
+ content.append(
1256
+ {
1257
+ "type": "thinking",
1258
+ "thinking": content_part.reasoning,
1259
+ "signature": content_part.signature,
1260
+ }
1261
+ )
1262
+ elif isinstance(content_part, RedactedReasoningContent):
1263
+ if current_model == self.model:
1264
+ content.append(
1265
+ {
1266
+ "type": "redacted_thinking",
1267
+ "data": content_part.data,
1268
+ }
1269
+ )
1270
+ elif isinstance(content_part, TextContent):
1271
+ content.append(
1272
+ {
1273
+ "type": "text",
1274
+ "text": content_part.text,
1275
+ }
1276
+ )
1277
+ else:
1278
+ # Skip unsupported types eg OmmitedReasoningContent
1279
+ pass
1280
+
1281
+ else:
1282
+ # COT / reasoning / thinking
1283
+ if self.content is not None and len(self.content) >= 1:
1284
+ for content_part in self.content:
1285
+ if isinstance(content_part, ReasoningContent):
1286
+ if current_model == self.model:
1287
+ content.append(
1288
+ {
1289
+ "type": "thinking",
1290
+ "thinking": content_part.reasoning,
1291
+ "signature": content_part.signature,
1292
+ }
1293
+ )
1294
+ if isinstance(content_part, RedactedReasoningContent):
1295
+ if current_model == self.model:
1296
+ content.append(
1297
+ {
1298
+ "type": "redacted_thinking",
1299
+ "data": content_part.data,
1300
+ }
1301
+ )
1302
+ if isinstance(content_part, TextContent):
1303
+ content.append(
1304
+ {
1305
+ "type": "text",
1306
+ "text": content_part.text,
1307
+ }
1308
+ )
1309
+ elif text_content is not None:
1310
+ content.append(
1311
+ {
1312
+ "type": "text",
1313
+ "text": add_xml_tag(string=text_content, xml_tag=inner_thoughts_xml_tag),
1314
+ }
1315
+ )
969
1316
  # Tool calling
970
1317
  if self.tool_calls is not None:
971
1318
  for tool_call in self.tool_calls:
@@ -978,6 +1325,9 @@ class Message(BaseMessage):
978
1325
  else:
979
1326
  tool_call_input = parse_json(tool_call.function.arguments)
980
1327
 
1328
+ if strip_request_heartbeat:
1329
+ tool_call_input.pop(REQUEST_HEARTBEAT_PARAM, None)
1330
+
981
1331
  content.append(
982
1332
  {
983
1333
  "type": "tool_use",
@@ -987,8 +1337,6 @@ class Message(BaseMessage):
987
1337
  }
988
1338
  )
989
1339
 
990
- # If the only content was text, unpack it back into a singleton
991
- # TODO support multi-modal
992
1340
  anthropic_message["content"] = content
993
1341
 
994
1342
  elif self.role == "tool":
@@ -1014,23 +1362,40 @@ class Message(BaseMessage):
1014
1362
  @staticmethod
1015
1363
  def to_anthropic_dicts_from_list(
1016
1364
  messages: List[Message],
1365
+ current_model: str,
1017
1366
  inner_thoughts_xml_tag: str = "thinking",
1018
1367
  put_inner_thoughts_in_kwargs: bool = False,
1368
+ # if true, then treat the content field as AssistantMessage
1369
+ native_content: bool = False,
1370
+ strip_request_heartbeat: bool = False,
1019
1371
  ) -> List[dict]:
1372
+ messages = Message.filter_messages_for_llm_api(messages)
1020
1373
  result = [
1021
1374
  m.to_anthropic_dict(
1375
+ current_model=current_model,
1022
1376
  inner_thoughts_xml_tag=inner_thoughts_xml_tag,
1023
1377
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1378
+ native_content=native_content,
1379
+ strip_request_heartbeat=strip_request_heartbeat,
1024
1380
  )
1025
1381
  for m in messages
1026
1382
  ]
1027
1383
  result = [m for m in result if m is not None]
1028
1384
  return result
1029
1385
 
1030
- def to_google_dict(self, put_inner_thoughts_in_kwargs: bool = True) -> dict | None:
1386
+ def to_google_dict(
1387
+ self,
1388
+ current_model: str,
1389
+ put_inner_thoughts_in_kwargs: bool = True,
1390
+ # if true, then treat the content field as AssistantMessage
1391
+ native_content: bool = False,
1392
+ strip_request_heartbeat: bool = False,
1393
+ ) -> dict | None:
1031
1394
  """
1032
1395
  Go from Message class to Google AI REST message object
1033
1396
  """
1397
+ assert not (native_content and put_inner_thoughts_in_kwargs), "native_content and put_inner_thoughts_in_kwargs cannot both be true"
1398
+
1034
1399
  if self.role == "approval" and self.tool_calls is None:
1035
1400
  return None
1036
1401
 
@@ -1080,7 +1445,7 @@ class Message(BaseMessage):
1080
1445
  }
1081
1446
 
1082
1447
  elif self.role == "assistant" or self.role == "approval":
1083
- assert self.tool_calls is not None or text_content is not None
1448
+ assert self.tool_calls is not None or text_content is not None or len(self.content) > 1
1084
1449
  google_ai_message = {
1085
1450
  "role": "model", # NOTE: different
1086
1451
  }
@@ -1088,7 +1453,12 @@ class Message(BaseMessage):
1088
1453
  # NOTE: Google AI API doesn't allow non-null content + function call
1089
1454
  # To get around this, just two a two part message, inner thoughts first then
1090
1455
  parts = []
1091
- if not put_inner_thoughts_in_kwargs and text_content is not None:
1456
+
1457
+ if native_content and text_content is not None:
1458
+ # TODO support multi-part assistant content
1459
+ parts.append({"text": text_content})
1460
+
1461
+ elif not put_inner_thoughts_in_kwargs and text_content is not None:
1092
1462
  # NOTE: ideally we do multi-part for CoT / inner thoughts + function call, but Google AI API doesn't allow it
1093
1463
  raise NotImplementedError
1094
1464
  parts.append({"text": text_content})
@@ -1110,6 +1480,9 @@ class Message(BaseMessage):
1110
1480
  assert len(self.tool_calls) == 1
1111
1481
  function_args[INNER_THOUGHTS_KWARG_VERTEX] = text_content
1112
1482
 
1483
+ if strip_request_heartbeat:
1484
+ function_args.pop(REQUEST_HEARTBEAT_PARAM, None)
1485
+
1113
1486
  parts.append(
1114
1487
  {
1115
1488
  "functionCall": {
@@ -1119,8 +1492,37 @@ class Message(BaseMessage):
1119
1492
  }
1120
1493
  )
1121
1494
  else:
1122
- assert text_content is not None
1123
- parts.append({"text": text_content})
1495
+ if not native_content:
1496
+ assert text_content is not None
1497
+ parts.append({"text": text_content})
1498
+
1499
+ if self.content and len(self.content) > 1:
1500
+ native_google_content_parts = []
1501
+ for content in self.content:
1502
+ if isinstance(content, TextContent):
1503
+ native_part = {"text": content.text}
1504
+ if content.signature and current_model == self.model:
1505
+ native_part["thought_signature"] = content.signature
1506
+ native_google_content_parts.append(native_part)
1507
+ elif isinstance(content, ReasoningContent):
1508
+ if current_model == self.model:
1509
+ native_google_content_parts.append({"text": content.reasoning, "thought": True})
1510
+ elif isinstance(content, ToolCallContent):
1511
+ native_part = {
1512
+ "function_call": {
1513
+ "name": content.name,
1514
+ "args": content.input,
1515
+ },
1516
+ }
1517
+ if content.signature and current_model == self.model:
1518
+ native_part["thought_signature"] = content.signature
1519
+ native_google_content_parts.append(native_part)
1520
+ else:
1521
+ # silently drop other content types
1522
+ pass
1523
+ if native_google_content_parts:
1524
+ parts = native_google_content_parts
1525
+
1124
1526
  google_ai_message["parts"] = parts
1125
1527
 
1126
1528
  elif self.role == "tool":
@@ -1170,17 +1572,61 @@ class Message(BaseMessage):
1170
1572
  @staticmethod
1171
1573
  def to_google_dicts_from_list(
1172
1574
  messages: List[Message],
1575
+ current_model: str,
1173
1576
  put_inner_thoughts_in_kwargs: bool = True,
1577
+ native_content: bool = False,
1174
1578
  ):
1579
+ messages = Message.filter_messages_for_llm_api(messages)
1175
1580
  result = [
1176
1581
  m.to_google_dict(
1582
+ current_model=current_model,
1177
1583
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1584
+ native_content=native_content,
1178
1585
  )
1179
1586
  for m in messages
1180
1587
  ]
1181
1588
  result = [m for m in result if m is not None]
1182
1589
  return result
1183
1590
 
1591
+ def is_approval_request(self) -> bool:
1592
+ return self.role == "approval" and self.tool_calls is not None and len(self.tool_calls) > 0
1593
+
1594
+ def is_approval_response(self) -> bool:
1595
+ return self.role == "approval" and self.tool_calls is None and self.approve is not None
1596
+
1597
+ def is_summarization_message(self) -> bool:
1598
+ return (
1599
+ self.role == "user"
1600
+ and self.content is not None
1601
+ and len(self.content) == 1
1602
+ and isinstance(self.content[0], TextContent)
1603
+ and "system_alert" in self.content[0].text
1604
+ )
1605
+
1606
+ @staticmethod
1607
+ def filter_messages_for_llm_api(
1608
+ messages: List[Message],
1609
+ ) -> List[Message]:
1610
+ messages = [m for m in messages if m is not None]
1611
+ if len(messages) == 0:
1612
+ return []
1613
+ # Add special handling for legacy bug where summarization triggers in the middle of hitl
1614
+ messages_to_filter = []
1615
+ for i in range(len(messages) - 1):
1616
+ first_message_is_approval = messages[i].is_approval_request()
1617
+ second_message_is_summary = messages[i + 1].is_summarization_message()
1618
+ third_message_is_optional_approval = i + 2 >= len(messages) or messages[i + 2].is_approval_response()
1619
+ if first_message_is_approval and second_message_is_summary and third_message_is_optional_approval:
1620
+ messages_to_filter.append(messages[i])
1621
+ for idx in reversed(messages_to_filter): # reverse to avoid index shift
1622
+ messages.remove(idx)
1623
+
1624
+ # Filter last message if it is a lone approval request without a response - this only occurs for token counting
1625
+ if messages[-1].role == "approval" and messages[-1].tool_calls is not None and len(messages[-1].tool_calls) > 0:
1626
+ messages.remove(messages[-1])
1627
+
1628
+ return messages
1629
+
1184
1630
  @staticmethod
1185
1631
  def generate_otid_from_id(message_id: str, index: int) -> str:
1186
1632
  """
@@ -1200,10 +1646,11 @@ class Message(BaseMessage):
1200
1646
 
1201
1647
 
1202
1648
  class ToolReturn(BaseModel):
1649
+ tool_call_id: Optional[Any] = Field(None, description="The ID for the tool call")
1203
1650
  status: Literal["success", "error"] = Field(..., description="The status of the tool call")
1204
1651
  stdout: Optional[List[str]] = Field(default=None, description="Captured stdout (e.g. prints, logs) from the tool invocation")
1205
1652
  stderr: Optional[List[str]] = Field(default=None, description="Captured stderr from the tool invocation")
1206
- # func_return: Optional[Any] = Field(None, description="The function return object")
1653
+ func_response: Optional[str] = Field(None, description="The function response string")
1207
1654
 
1208
1655
 
1209
1656
  class MessageSearchRequest(BaseModel):