Skip to content

Commit ed710aa

Browse files
authored
Merge branch 'main' into issue-636-hitl-2
2 parents 9a26137 + ec257bb commit ed710aa

File tree

2 files changed

+131
-0
lines changed

2 files changed

+131
-0
lines changed

src/agents/models/chatcmpl_converter.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,24 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
535535
combined = "\n".join(text_segments)
536536
new_asst["content"] = combined
537537

538+
# If we have pending thinking blocks, prepend them to the content
539+
# This is required for Anthropic API with interleaved thinking
540+
if pending_thinking_blocks:
541+
# If there is a text content, convert it to a list to prepend thinking blocks
542+
if "content" in new_asst and isinstance(new_asst["content"], str):
543+
text_content = ChatCompletionContentPartTextParam(
544+
text=new_asst["content"], type="text"
545+
)
546+
new_asst["content"] = [text_content]
547+
548+
if "content" not in new_asst or new_asst["content"] is None:
549+
new_asst["content"] = []
550+
551+
# Thinking blocks MUST come before any other content
552+
# We ignore type errors because pending_thinking_blocks is not openai standard
553+
new_asst["content"] = pending_thinking_blocks + new_asst["content"] # type: ignore
554+
pending_thinking_blocks = None # Clear after using
555+
538556
new_asst["tool_calls"] = []
539557
current_assistant_msg = new_asst
540558

tests/test_anthropic_thinking_blocks.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,116 @@ def test_anthropic_thinking_blocks_with_tool_calls():
246246
tool_calls = assistant_msg.get("tool_calls", [])
247247
assert len(cast(list[Any], tool_calls)) == 1, "Tool calls should be preserved"
248248
assert cast(list[Any], tool_calls)[0]["function"]["name"] == "get_weather"
249+
250+
251+
def test_anthropic_thinking_blocks_without_tool_calls():
252+
"""
253+
Test for models with extended thinking WITHOUT tool calls.
254+
255+
This test verifies that thinking blocks are properly attached to assistant
256+
messages even when there are no tool calls (fixes issue #2195).
257+
"""
258+
# Create a message with reasoning and thinking blocks but NO tool calls
259+
message = InternalChatCompletionMessage(
260+
role="assistant",
261+
content="The weather in Paris is sunny with a temperature of 22°C.",
262+
reasoning_content="The user wants to know about the weather in Paris.",
263+
thinking_blocks=[
264+
{
265+
"type": "thinking",
266+
"thinking": "Let me think about the weather in Paris.",
267+
"signature": "TestSignatureNoTools123",
268+
}
269+
],
270+
tool_calls=None, # No tool calls
271+
)
272+
273+
# Step 1: Convert message to output items
274+
output_items = Converter.message_to_output_items(message)
275+
276+
# Verify reasoning item exists and contains thinking blocks
277+
reasoning_items = [
278+
item for item in output_items if hasattr(item, "type") and item.type == "reasoning"
279+
]
280+
assert len(reasoning_items) == 1, "Should have exactly one reasoning item"
281+
282+
reasoning_item = reasoning_items[0]
283+
284+
# Verify thinking text is stored in content
285+
assert hasattr(reasoning_item, "content") and reasoning_item.content, (
286+
"Reasoning item should have content"
287+
)
288+
assert reasoning_item.content[0].type == "reasoning_text", (
289+
"Content should be reasoning_text type"
290+
)
291+
assert reasoning_item.content[0].text == "Let me think about the weather in Paris.", (
292+
"Thinking text should be preserved"
293+
)
294+
295+
# Verify signature is stored in encrypted_content
296+
assert hasattr(reasoning_item, "encrypted_content"), (
297+
"Reasoning item should have encrypted_content"
298+
)
299+
assert reasoning_item.encrypted_content == "TestSignatureNoTools123", (
300+
"Signature should be preserved"
301+
)
302+
303+
# Verify message item exists
304+
message_items = [
305+
item for item in output_items if hasattr(item, "type") and item.type == "message"
306+
]
307+
assert len(message_items) == 1, "Should have exactly one message item"
308+
309+
# Step 2: Convert output items back to messages with preserve_thinking_blocks=True
310+
items_as_dicts: list[dict[str, Any]] = []
311+
for item in output_items:
312+
if hasattr(item, "model_dump"):
313+
items_as_dicts.append(item.model_dump())
314+
else:
315+
items_as_dicts.append(cast(dict[str, Any], item))
316+
317+
messages = Converter.items_to_messages(
318+
items_as_dicts, # type: ignore[arg-type]
319+
model="anthropic/claude-4-opus",
320+
preserve_thinking_blocks=True,
321+
)
322+
323+
# Should have one assistant message
324+
assistant_messages = [msg for msg in messages if msg.get("role") == "assistant"]
325+
assert len(assistant_messages) == 1, "Should have exactly one assistant message"
326+
327+
assistant_msg = assistant_messages[0]
328+
329+
# Content must start with thinking blocks even WITHOUT tool calls
330+
content = assistant_msg.get("content")
331+
assert content is not None, "Assistant message should have content"
332+
assert isinstance(content, list), (
333+
f"Assistant message content should be a list when thinking blocks are present, "
334+
f"but got {type(content)}"
335+
)
336+
assert len(content) >= 2, (
337+
f"Assistant message should have at least 2 content items "
338+
f"(thinking + text), got {len(content)}"
339+
)
340+
341+
# First content should be thinking block
342+
first_content = content[0]
343+
assert first_content.get("type") == "thinking", (
344+
f"First content must be 'thinking' type for Anthropic compatibility, "
345+
f"but got '{first_content.get('type')}'"
346+
)
347+
assert first_content.get("thinking") == "Let me think about the weather in Paris.", (
348+
"Thinking content should be preserved"
349+
)
350+
assert first_content.get("signature") == "TestSignatureNoTools123", (
351+
"Signature should be preserved in thinking block"
352+
)
353+
354+
# Second content should be text
355+
second_content = content[1]
356+
assert second_content.get("type") == "text", (
357+
f"Second content must be 'text' type, but got '{second_content.get('type')}'"
358+
)
359+
assert (
360+
second_content.get("text") == "The weather in Paris is sunny with a temperature of 22°C."
361+
), "Text content should be preserved"

0 commit comments

Comments
 (0)