Skip to content

Commit d81891f

Browse files
committed
fix: reorder tool messages to resolve interleaved thinking bug
1 parent 4f54878 commit d81891f

File tree

2 files changed

+117
-2
lines changed

2 files changed

+117
-2
lines changed

src/agents/extensions/models/litellm_model.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ChatCompletionChunk,
2424
ChatCompletionMessageCustomToolCall,
2525
ChatCompletionMessageFunctionToolCall,
26+
ChatCompletionMessageParam,
2627
)
2728
from openai.types.chat.chat_completion_message import (
2829
Annotation,
@@ -267,6 +268,10 @@ async def _fetch_response(
267268
input, preserve_thinking_blocks=preserve_thinking_blocks
268269
)
269270

271+
# Fix for interleaved thinking bug: reorder messages to ensure tool_use comes before tool_result # noqa: E501
272+
if preserve_thinking_blocks:
273+
converted_messages = self._fix_tool_message_ordering(converted_messages)
274+
270275
if system_instructions:
271276
converted_messages.insert(
272277
0,
@@ -379,6 +384,112 @@ async def _fetch_response(
379384
)
380385
return response, ret
381386

387+
def _fix_tool_message_ordering(
388+
self, messages: list[ChatCompletionMessageParam]
389+
) -> list[ChatCompletionMessageParam]:
390+
"""
391+
Fix the ordering of tool messages to ensure tool_use messages come before tool_result messages.
392+
393+
This addresses the interleaved thinking bug where conversation histories may contain
394+
tool results before their corresponding tool calls, causing Anthropic API to reject the request.
395+
""" # noqa: E501
396+
if not messages:
397+
return messages
398+
399+
# Collect all tool calls and tool results
400+
tool_call_messages = {} # tool_id -> (index, message)
401+
tool_result_messages = {} # tool_id -> (index, message)
402+
other_messages = [] # (index, message) for non-tool messages
403+
404+
for i, message in enumerate(messages):
405+
if not isinstance(message, dict):
406+
other_messages.append((i, message))
407+
continue
408+
409+
role = message.get("role")
410+
411+
if role == "assistant" and message.get("tool_calls"):
412+
# Extract tool calls from this assistant message
413+
tool_calls = message.get("tool_calls", [])
414+
if isinstance(tool_calls, list):
415+
for tool_call in tool_calls:
416+
if isinstance(tool_call, dict):
417+
tool_id = tool_call.get("id")
418+
if tool_id:
419+
# Create a separate assistant message for each tool call
420+
single_tool_msg = cast(dict[str, Any], message.copy())
421+
single_tool_msg["tool_calls"] = [tool_call]
422+
tool_call_messages[tool_id] = (
423+
i,
424+
cast(ChatCompletionMessageParam, single_tool_msg),
425+
)
426+
427+
elif role == "tool":
428+
tool_call_id = message.get("tool_call_id")
429+
if tool_call_id:
430+
tool_result_messages[tool_call_id] = (i, message)
431+
else:
432+
other_messages.append((i, message))
433+
else:
434+
other_messages.append((i, message))
435+
436+
# Create the fixed message sequence
437+
fixed_messages: list[ChatCompletionMessageParam] = []
438+
used_indices = set()
439+
440+
# Add messages in their original order, but ensure tool_use → tool_result pairing
441+
for i, original_message in enumerate(messages):
442+
if i in used_indices:
443+
continue
444+
445+
if not isinstance(original_message, dict):
446+
fixed_messages.append(original_message)
447+
used_indices.add(i)
448+
continue
449+
450+
role = original_message.get("role")
451+
452+
if role == "assistant" and original_message.get("tool_calls"):
453+
# Process each tool call in this assistant message
454+
tool_calls = original_message.get("tool_calls", [])
455+
if isinstance(tool_calls, list):
456+
for tool_call in tool_calls:
457+
if isinstance(tool_call, dict):
458+
tool_id = tool_call.get("id")
459+
if (
460+
tool_id
461+
and tool_id in tool_call_messages
462+
and tool_id in tool_result_messages
463+
):
464+
# Add tool_use → tool_result pair
465+
_, tool_call_msg = tool_call_messages[tool_id]
466+
_, tool_result_msg = tool_result_messages[tool_id]
467+
468+
fixed_messages.append(tool_call_msg)
469+
fixed_messages.append(tool_result_msg)
470+
471+
# Mark both as used
472+
used_indices.add(tool_call_messages[tool_id][0])
473+
used_indices.add(tool_result_messages[tool_id][0])
474+
elif tool_id and tool_id in tool_call_messages:
475+
# Tool call without result - add just the tool call
476+
_, tool_call_msg = tool_call_messages[tool_id]
477+
fixed_messages.append(tool_call_msg)
478+
used_indices.add(tool_call_messages[tool_id][0])
479+
480+
used_indices.add(i) # Mark original multi-tool message as used
481+
482+
elif role == "tool":
483+
# Skip - these will be handled as part of tool pairs above
484+
used_indices.add(i)
485+
486+
else:
487+
# Regular message - add it normally
488+
fixed_messages.append(original_message)
489+
used_indices.add(i)
490+
491+
return fixed_messages
492+
382493
def _remove_not_given(self, value: Any) -> Any:
383494
if isinstance(value, NotGiven):
384495
return None

src/agents/models/chatcmpl_converter.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
533533

534534
if content_items and preserve_thinking_blocks:
535535
# Reconstruct thinking blocks from content and signature
536-
pending_thinking_blocks = []
536+
reconstructed_thinking_blocks = []
537537
for content_item in content_items:
538538
if (
539539
isinstance(content_item, dict)
@@ -546,7 +546,11 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
546546
# Add signatures if available
547547
if signatures:
548548
thinking_block["signature"] = signatures.pop(0)
549-
pending_thinking_blocks.append(thinking_block)
549+
reconstructed_thinking_blocks.append(thinking_block)
550+
551+
# Store thinking blocks as pending for the next assistant message
552+
# This preserves the original behavior
553+
pending_thinking_blocks = reconstructed_thinking_blocks
550554

551555
# 8) If we haven't recognized it => fail or ignore
552556
else:

0 commit comments

Comments
 (0)