Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions src/agents/models/chatcmpl_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TRespon
if hasattr(message, "thinking_blocks") and message.thinking_blocks:
# Store thinking text in content and signature in encrypted_content
reasoning_item.content = []
signature = None
signatures: list[str] = []
for block in message.thinking_blocks:
if isinstance(block, dict):
thinking_text = block.get("thinking", "")
Expand All @@ -116,15 +116,12 @@ def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TRespon
Content(text=thinking_text, type="reasoning_text")
)
# Store the signature if present
if block.get("signature"):
signature = block.get("signature")
if signature := block.get("signature"):
signatures.append(signature)

# Store only the last signature in encrypted_content
# If there are multiple thinking blocks, this should be a problem.
# In practice, there should only be one signature for the entire reasoning step.
# Tested with: claude-sonnet-4-20250514
if signature:
reasoning_item.encrypted_content = signature
# Store the signatures in encrypted_content with newline delimiter
if signatures:
reasoning_item.encrypted_content = "\n".join(signatures)

items.append(reasoning_item)

Expand Down Expand Up @@ -518,7 +515,8 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
elif reasoning_item := cls.maybe_reasoning_message(item):
# Reconstruct thinking blocks from content (text) and encrypted_content (signature)
content_items = reasoning_item.get("content", [])
signature = reasoning_item.get("encrypted_content")
encrypted_content = reasoning_item.get("encrypted_content")
signatures = encrypted_content.split("\n") if encrypted_content else []

if content_items and preserve_thinking_blocks:
# Reconstruct thinking blocks from content and signature
Expand All @@ -532,9 +530,9 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
"type": "thinking",
"thinking": content_item.get("text", ""),
}
# Add signature if available
if signature:
thinking_block["signature"] = signature
# Add signatures if available
if signatures:
thinking_block["signature"] = signatures.pop(0)
Comment on lines 530 to +535

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve signature alignment when some thinking blocks lack signatures

message_to_output_items now collects only the signatures that are present and stores them as a newline‑joined string, but items_to_messages reconstructs thinking blocks by popping from that list for every reasoning block in order. If a response contains multiple thinking blocks where some early blocks do not have signatures, this logic shifts the later signatures onto earlier blocks and drops any trailing ones (e.g., blocks [no sig, sigA, sigB] become [sigA, sigB, none]). This produces incorrect signature/block pairing and will break downstream verification whenever not all blocks carry a signature. Consider storing signature placeholders (e.g., None) for blocks without signatures or associating signatures with their block index before reconstruction.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reasoning blocks should always contain signatures, this is basically a N:1 conversion and 1:N back-conversion. So N should always equal. if signatures is simply a defensive coding practice.

pending_thinking_blocks.append(thinking_block)

# 8) If we haven't recognized it => fail or ignore
Expand Down
27 changes: 24 additions & 3 deletions tests/test_anthropic_thinking_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ def test_anthropic_thinking_blocks_with_tool_calls():
"Let me use the weather tool to get this information."
),
"signature": "TestSignature123",
}
},
{
"type": "thinking",
"thinking": ("We should use the city Tokyo as the city."),
"signature": "TestSignature456",
},
],
tool_calls=[
ChatCompletionMessageToolCall(
Expand All @@ -143,7 +148,7 @@ def test_anthropic_thinking_blocks_with_tool_calls():
reasoning_items = [
item for item in output_items if hasattr(item, "type") and item.type == "reasoning"
]
assert len(reasoning_items) == 1, "Should have exactly one reasoning item"
assert len(reasoning_items) == 1, "Should have exactly two reasoning items"

reasoning_item = reasoning_items[0]

Expand All @@ -159,7 +164,9 @@ def test_anthropic_thinking_blocks_with_tool_calls():
assert hasattr(reasoning_item, "encrypted_content"), (
"Reasoning item should have encrypted_content"
)
assert reasoning_item.encrypted_content == "TestSignature123", "Signature should be preserved"
assert reasoning_item.encrypted_content == "TestSignature123\nTestSignature456", (
"Signature should be preserved"
)

# Verify tool calls are present
tool_call_items = [
Expand Down Expand Up @@ -210,6 +217,20 @@ def test_anthropic_thinking_blocks_with_tool_calls():
"Signature should be preserved in thinking block"
)

first_content = content[1]
assert first_content.get("type") == "thinking", (
f"Second content must be 'thinking' type for Anthropic compatibility, "
f"but got '{first_content.get('type')}'"
)
expected_thinking = "We should use the city Tokyo as the city."
assert first_content.get("thinking") == expected_thinking, (
"Thinking content should be preserved"
)
# Signature should also be preserved
assert first_content.get("signature") == "TestSignature456", (
"Signature should be preserved in thinking block"
)

# Verify tool calls are preserved
tool_calls = assistant_msg.get("tool_calls", [])
assert len(cast(list[Any], tool_calls)) == 1, "Tool calls should be preserved"
Expand Down